diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb new file mode 100644 index 00000000..bbd8a659 --- /dev/null +++ b/app/controllers/api/v1/base_controller.rb @@ -0,0 +1,6 @@ +module Api + module V1 + class BaseController < ActionController::API + end + end +end diff --git a/app/controllers/api/v1/beacons/base_controller.rb b/app/controllers/api/v1/beacons/base_controller.rb new file mode 100644 index 00000000..1c0380a2 --- /dev/null +++ b/app/controllers/api/v1/beacons/base_controller.rb @@ -0,0 +1,9 @@ +module Api + module V1 + module Beacons + class BaseController < Api::V1::BaseController + include Api::BeaconAuthentication + end + end + end +end diff --git a/app/controllers/api/v1/beacons/statuses_controller.rb b/app/controllers/api/v1/beacons/statuses_controller.rb new file mode 100644 index 00000000..1cc84ed1 --- /dev/null +++ b/app/controllers/api/v1/beacons/statuses_controller.rb @@ -0,0 +1,17 @@ +module Api + module V1 + module Beacons + class StatusesController < Beacons::BaseController + def show + render json: { + status: "ok", + beacon: { + id: Current.beacon.id, + name: Current.beacon.name, + }, + } + end + end + end + end +end diff --git a/app/controllers/concerns/api/beacon_authentication.rb b/app/controllers/concerns/api/beacon_authentication.rb new file mode 100644 index 00000000..d9e32340 --- /dev/null +++ b/app/controllers/concerns/api/beacon_authentication.rb @@ -0,0 +1,35 @@ +module Api + module BeaconAuthentication + extend ActiveSupport::Concern + + included do + before_action :authenticate_beacon! + end + + private + + def authenticate_beacon! + token = extract_bearer_token + return render_unauthorized("Missing authorization header") if token.blank? + + digest = OpenSSL::Digest::SHA256.hexdigest(token) + beacon = Beacon.find_by(api_key_digest: digest) + + return render_unauthorized("Invalid API key") if beacon.nil? + return render_unauthorized("API key has been revoked") if beacon.revoked? + + Current.beacon = beacon + end + + def extract_bearer_token + header = request.headers["Authorization"] + return nil if header.blank? + + header[/\ABearer\s+(.+)\z/, 1] + end + + def render_unauthorized(message) + render json: { error: message }, status: :unauthorized + end + end +end diff --git a/app/models/beacon.rb b/app/models/beacon.rb new file mode 100644 index 00000000..d613ae38 --- /dev/null +++ b/app/models/beacon.rb @@ -0,0 +1,51 @@ +# == Schema Information +# +# Table name: beacons +# Database name: primary +# +# id :bigint not null, primary key +# api_key_digest :string not null +# api_key_prefix :string not null +# name :string not null +# revoked_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# language_id :bigint not null +# region_id :bigint not null +# +# Indexes +# +# index_beacons_on_api_key_digest (api_key_digest) UNIQUE +# index_beacons_on_language_id (language_id) +# index_beacons_on_region_id (region_id) +# +# Foreign Keys +# +# fk_rails_... (language_id => languages.id) +# fk_rails_... (region_id => regions.id) +# +class Beacon < ApplicationRecord + belongs_to :language + belongs_to :region + + has_many :beacon_providers, dependent: :destroy + has_many :providers, through: :beacon_providers + + has_many :beacon_topics, dependent: :destroy + has_many :topics, through: :beacon_topics + + validates :name, presence: true + validates :api_key_digest, presence: true, uniqueness: true + validates :api_key_prefix, presence: true + + scope :active, -> { where(revoked_at: nil) } + scope :revoked, -> { where.not(revoked_at: nil) } + + def revoke! + update!(revoked_at: Time.current) + end + + def revoked? + revoked_at.present? + end +end diff --git a/app/models/beacon_provider.rb b/app/models/beacon_provider.rb new file mode 100644 index 00000000..4dd8ec96 --- /dev/null +++ b/app/models/beacon_provider.rb @@ -0,0 +1,26 @@ +# == Schema Information +# +# Table name: beacon_providers +# Database name: primary +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# beacon_id :bigint not null +# provider_id :bigint not null +# +# Indexes +# +# index_beacon_providers_on_beacon_id (beacon_id) +# index_beacon_providers_on_beacon_id_and_provider_id (beacon_id,provider_id) UNIQUE +# index_beacon_providers_on_provider_id (provider_id) +# +# Foreign Keys +# +# fk_rails_... (beacon_id => beacons.id) +# fk_rails_... (provider_id => providers.id) +# +class BeaconProvider < ApplicationRecord + belongs_to :beacon + belongs_to :provider +end diff --git a/app/models/beacon_topic.rb b/app/models/beacon_topic.rb new file mode 100644 index 00000000..1d67ff44 --- /dev/null +++ b/app/models/beacon_topic.rb @@ -0,0 +1,26 @@ +# == Schema Information +# +# Table name: beacon_topics +# Database name: primary +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# beacon_id :bigint not null +# topic_id :bigint not null +# +# Indexes +# +# index_beacon_topics_on_beacon_id (beacon_id) +# index_beacon_topics_on_beacon_id_and_topic_id (beacon_id,topic_id) UNIQUE +# index_beacon_topics_on_topic_id (topic_id) +# +# Foreign Keys +# +# fk_rails_... (beacon_id => beacons.id) +# fk_rails_... (topic_id => topics.id) +# +class BeaconTopic < ApplicationRecord + belongs_to :beacon + belongs_to :topic +end diff --git a/app/models/branch.rb b/app/models/branch.rb index 1bae447d..bdb20739 100644 --- a/app/models/branch.rb +++ b/app/models/branch.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: branches +# Database name: primary # # id :bigint not null, primary key # created_at :datetime not null diff --git a/app/models/contributor.rb b/app/models/contributor.rb index bc3d3527..8109fdd8 100644 --- a/app/models/contributor.rb +++ b/app/models/contributor.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: contributors +# Database name: primary # # id :bigint not null, primary key # created_at :datetime not null diff --git a/app/models/current.rb b/app/models/current.rb index 2bef56da..5725e6e2 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,4 +1,5 @@ class Current < ActiveSupport::CurrentAttributes attribute :session + attribute :beacon delegate :user, to: :session, allow_nil: true end diff --git a/app/models/import_error.rb b/app/models/import_error.rb index 0b20b124..8d14c592 100644 --- a/app/models/import_error.rb +++ b/app/models/import_error.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: import_errors +# Database name: primary # # id :bigint not null, primary key # error_message :text diff --git a/app/models/import_report.rb b/app/models/import_report.rb index d895e205..48c1f817 100644 --- a/app/models/import_report.rb +++ b/app/models/import_report.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: import_reports +# Database name: primary # # id :bigint not null, primary key # completed_at :datetime diff --git a/app/models/language.rb b/app/models/language.rb index ceb5cb96..4c1f78fb 100644 --- a/app/models/language.rb +++ b/app/models/language.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: languages +# Database name: primary # # id :bigint not null, primary key # name :string @@ -10,6 +11,7 @@ class Language < ApplicationRecord has_many :topics, dependent: :destroy has_many :providers, through: :topics + has_many :beacons, dependent: :destroy validates :name, presence: true, uniqueness: true, length: { minimum: 2 } diff --git a/app/models/provider.rb b/app/models/provider.rb index 8505cc0c..2a448d8e 100644 --- a/app/models/provider.rb +++ b/app/models/provider.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: providers +# Database name: primary # # id :bigint not null, primary key # file_name_prefix :string @@ -20,6 +21,8 @@ class Provider < ApplicationRecord has_many :contributors has_many :users, through: :contributors has_many :topics + has_many :beacon_providers, dependent: :destroy + has_many :beacons, through: :beacon_providers validates :name, :provider_type, presence: true validates :name, uniqueness: true diff --git a/app/models/region.rb b/app/models/region.rb index 3838071c..be4b4ee8 100644 --- a/app/models/region.rb +++ b/app/models/region.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: regions +# Database name: primary # # id :bigint not null, primary key # name :string @@ -10,6 +11,7 @@ class Region < ApplicationRecord has_many :branches, dependent: :destroy has_many :providers, through: :branches + has_many :beacons, dependent: :destroy validates :name, presence: true end diff --git a/app/models/session.rb b/app/models/session.rb index 688bbd30..56416c02 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: sessions +# Database name: primary # # id :bigint not null, primary key # ip_address :string diff --git a/app/models/tag.rb b/app/models/tag.rb index ceb7965c..8da104b9 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: tags +# Database name: primary # # id :bigint not null, primary key # name :string diff --git a/app/models/tag_cognate.rb b/app/models/tag_cognate.rb index 9d19b234..54b5fe76 100644 --- a/app/models/tag_cognate.rb +++ b/app/models/tag_cognate.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: tag_cognates +# Database name: primary # # id :bigint not null, primary key # created_at :datetime not null diff --git a/app/models/topic.rb b/app/models/topic.rb index b4b839a2..f78ebb2a 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: topics +# Database name: primary # # id :bigint not null, primary key # description :text @@ -35,6 +36,8 @@ class Topic < ApplicationRecord belongs_to :language belongs_to :provider + has_many :beacon_topics, dependent: :destroy + has_many :beacons, through: :beacon_topics has_many_attached :documents, dependent: :purge validates :title, :language_id, :provider_id, :published_at, presence: true diff --git a/app/models/user.rb b/app/models/user.rb index 99aabb72..aef1fc1a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: users +# Database name: primary # # id :bigint not null, primary key # email :string not null diff --git a/app/services/beacons/api_key_generator.rb b/app/services/beacons/api_key_generator.rb new file mode 100644 index 00000000..dbd015cc --- /dev/null +++ b/app/services/beacons/api_key_generator.rb @@ -0,0 +1,15 @@ +module Beacons + class ApiKeyGenerator + PREFIX = "sk_live_" + + def call + raw_key = "#{PREFIX}#{SecureRandom.hex(16)}" + digest = OpenSSL::Digest::SHA256.hexdigest(raw_key) + prefix = raw_key.delete_prefix(PREFIX)[0, 8] + + Result.new(raw_key: raw_key, digest: digest, prefix: prefix) + end + + Result = Data.define(:raw_key, :digest, :prefix) + end +end diff --git a/app/services/beacons/creator.rb b/app/services/beacons/creator.rb new file mode 100644 index 00000000..cbb18398 --- /dev/null +++ b/app/services/beacons/creator.rb @@ -0,0 +1,25 @@ +module Beacons + class Creator + attr_reader :key_generator + + def initialize(key_generator: ApiKeyGenerator.new) + @key_generator = key_generator + end + + def call(params) + key_result = key_generator.call + + beacon = Beacon.new( + **params, + api_key_digest: key_result.digest, + api_key_prefix: key_result.prefix, + ) + + if beacon.save + [ true, beacon, key_result.raw_key ] + else + [ false, beacon, nil ] + end + end + end +end diff --git a/app/services/beacons/key_regenerator.rb b/app/services/beacons/key_regenerator.rb new file mode 100644 index 00000000..ac4e4923 --- /dev/null +++ b/app/services/beacons/key_regenerator.rb @@ -0,0 +1,21 @@ +module Beacons + class KeyRegenerator + attr_reader :key_generator + + def initialize(key_generator: ApiKeyGenerator.new) + @key_generator = key_generator + end + + def call(beacon) + key_result = key_generator.call + + beacon.update!( + api_key_digest: key_result.digest, + api_key_prefix: key_result.prefix, + revoked_at: nil, + ) + + [ beacon, key_result.raw_key ] + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 24879cbf..bfa18754 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -39,6 +39,10 @@ namespace :api do namespace :v1 do resources :tags, only: %i[index show] + + namespace :beacons do + resource :status, only: :show + end end end diff --git a/db/migrate/20260203100000_create_devices.rb b/db/migrate/20260203100000_create_devices.rb new file mode 100644 index 00000000..74248228 --- /dev/null +++ b/db/migrate/20260203100000_create_devices.rb @@ -0,0 +1,15 @@ +class CreateDevices < ActiveRecord::Migration[8.0] + def change + create_table :devices do |t| + t.string :name, null: false + t.string :api_key_digest, null: false + t.string :api_key_prefix, null: false + t.references :language, null: false, foreign_key: true + t.datetime :revoked_at + + t.timestamps + end + + add_index :devices, :api_key_digest, unique: true + end +end diff --git a/db/migrate/20260203100001_create_device_providers.rb b/db/migrate/20260203100001_create_device_providers.rb new file mode 100644 index 00000000..1dd57571 --- /dev/null +++ b/db/migrate/20260203100001_create_device_providers.rb @@ -0,0 +1,12 @@ +class CreateDeviceProviders < ActiveRecord::Migration[8.0] + def change + create_table :device_providers do |t| + t.references :device, null: false, foreign_key: true + t.references :provider, null: false, foreign_key: true + + t.timestamps + end + + add_index :device_providers, [ :device_id, :provider_id ], unique: true + end +end diff --git a/db/migrate/20260203100002_create_device_topics.rb b/db/migrate/20260203100002_create_device_topics.rb new file mode 100644 index 00000000..c3bc31f3 --- /dev/null +++ b/db/migrate/20260203100002_create_device_topics.rb @@ -0,0 +1,12 @@ +class CreateDeviceTopics < ActiveRecord::Migration[8.0] + def change + create_table :device_topics do |t| + t.references :device, null: false, foreign_key: true + t.references :topic, null: false, foreign_key: true + + t.timestamps + end + + add_index :device_topics, [ :device_id, :topic_id ], unique: true + end +end diff --git a/db/migrate/20260203100003_add_region_to_devices.rb b/db/migrate/20260203100003_add_region_to_devices.rb new file mode 100644 index 00000000..b12efed0 --- /dev/null +++ b/db/migrate/20260203100003_add_region_to_devices.rb @@ -0,0 +1,5 @@ +class AddRegionToDevices < ActiveRecord::Migration[8.0] + def change + add_reference :devices, :region, null: false, foreign_key: true + end +end diff --git a/db/migrate/20260203100004_rename_devices_to_beacons.rb b/db/migrate/20260203100004_rename_devices_to_beacons.rb new file mode 100644 index 00000000..ad52cc29 --- /dev/null +++ b/db/migrate/20260203100004_rename_devices_to_beacons.rb @@ -0,0 +1,10 @@ +class RenameDevicesToBeacons < ActiveRecord::Migration[8.0] + def change + rename_table :devices, :beacons + rename_table :device_providers, :beacon_providers + rename_table :device_topics, :beacon_topics + + rename_column :beacon_providers, :device_id, :beacon_id + rename_column :beacon_topics, :device_id, :beacon_id + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb index 089e9380..2181653f 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -10,47 +10,47 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 1) do +ActiveRecord::Schema[8.1].define(version: 1) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" create_table "solid_queue_blocked_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "queue_name", null: false - t.integer "priority", default: 0, null: false t.string "concurrency_key", null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true end create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false t.bigint "process_id" - t.datetime "created_at", null: false t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" end create_table "solid_queue_failed_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.text "error" t.datetime "created_at", null: false + t.text "error" + t.bigint "job_id", null: false t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true end create_table "solid_queue_jobs", force: :cascade do |t| - t.string "queue_name", null: false - t.string "class_name", null: false - t.text "arguments" - t.integer "priority", default: 0, null: false t.string "active_job_id" - t.datetime "scheduled_at" - t.datetime "finished_at" + t.text "arguments" + t.string "class_name", null: false t.string "concurrency_key" t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" t.datetime "updated_at", null: false t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" @@ -60,76 +60,76 @@ end create_table "solid_queue_pauses", force: :cascade do |t| - t.string "queue_name", null: false t.datetime "created_at", null: false + t.string "queue_name", null: false t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true end create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" t.string "kind", null: false t.datetime "last_heartbeat_at", null: false - t.bigint "supervisor_id" - t.integer "pid", null: false - t.string "hostname" t.text "metadata" - t.datetime "created_at", null: false t.string "name", null: false + t.integer "pid", null: false + t.bigint "supervisor_id" t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" end create_table "solid_queue_ready_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false - t.datetime "created_at", null: false + t.string "queue_name", null: false t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" end create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "task_key", null: false t.datetime "run_at", null: false - t.datetime "created_at", null: false + t.string "task_key", null: false t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true end create_table "solid_queue_recurring_tasks", force: :cascade do |t| - t.string "key", null: false - t.string "schedule", null: false - t.string "command", limit: 2048 - t.string "class_name" t.text "arguments" - t.string "queue_name" + t.string "class_name" + t.string "command", limit: 2048 + t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false t.boolean "static", default: true, null: false - t.text "description" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" end create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false + t.string "queue_name", null: false t.datetime "scheduled_at", null: false - t.datetime "created_at", null: false t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" end create_table "solid_queue_semaphores", force: :cascade do |t| - t.string "key", null: false - t.integer "value", default: 1, null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false t.datetime "updated_at", null: false + t.integer "value", default: 1, null: false t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true diff --git a/db/schema.rb b/db/schema.rb index fc2a8be0..46aaf864 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,29 +10,29 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_10_04_204227) do +ActiveRecord::Schema[8.1].define(version: 2026_02_03_100004) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" create_table "active_storage_attachments", force: :cascade do |t| - t.string "name", null: false - t.string "record_type", null: false - t.bigint "record_id", null: false t.bigint "blob_id", null: false t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", force: :cascade do |t| - t.string "key", null: false - t.string "filename", null: false - t.string "content_type" - t.text "metadata" - t.string "service_name", null: false t.bigint "byte_size", null: false t.string "checksum" + t.string "content_type" t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -42,32 +42,66 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "beacon_providers", force: :cascade do |t| + t.bigint "beacon_id", null: false + t.datetime "created_at", null: false + t.bigint "provider_id", null: false + t.datetime "updated_at", null: false + t.index ["beacon_id", "provider_id"], name: "index_beacon_providers_on_beacon_id_and_provider_id", unique: true + t.index ["beacon_id"], name: "index_beacon_providers_on_beacon_id" + t.index ["provider_id"], name: "index_beacon_providers_on_provider_id" + end + + create_table "beacon_topics", force: :cascade do |t| + t.bigint "beacon_id", null: false + t.datetime "created_at", null: false + t.bigint "topic_id", null: false + t.datetime "updated_at", null: false + t.index ["beacon_id", "topic_id"], name: "index_beacon_topics_on_beacon_id_and_topic_id", unique: true + t.index ["beacon_id"], name: "index_beacon_topics_on_beacon_id" + t.index ["topic_id"], name: "index_beacon_topics_on_topic_id" + end + + create_table "beacons", force: :cascade do |t| + t.string "api_key_digest", null: false + t.string "api_key_prefix", null: false + t.datetime "created_at", null: false + t.bigint "language_id", null: false + t.string "name", null: false + t.bigint "region_id", null: false + t.datetime "revoked_at" + t.datetime "updated_at", null: false + t.index ["api_key_digest"], name: "index_beacons_on_api_key_digest", unique: true + t.index ["language_id"], name: "index_beacons_on_language_id" + t.index ["region_id"], name: "index_beacons_on_region_id" + end + create_table "branches", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "provider_id" t.bigint "region_id" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["provider_id"], name: "index_branches_on_provider_id" t.index ["region_id"], name: "index_branches_on_region_id" end create_table "contributors", force: :cascade do |t| - t.bigint "provider_id" - t.bigint "user_id" t.datetime "created_at", null: false + t.bigint "provider_id" t.datetime "updated_at", null: false + t.bigint "user_id" t.index ["provider_id"], name: "index_contributors_on_provider_id" t.index ["user_id"], name: "index_contributors_on_user_id" end create_table "import_errors", force: :cascade do |t| - t.bigint "import_report_id", null: false + t.datetime "created_at", null: false + t.text "error_message" t.string "error_type", null: false t.string "file_name" - t.integer "topic_id" - t.text "error_message" + t.bigint "import_report_id", null: false t.json "metadata" - t.datetime "created_at", null: false + t.integer "topic_id" t.datetime "updated_at", null: false t.index ["error_type"], name: "index_import_errors_on_error_type" t.index ["file_name"], name: "index_import_errors_on_file_name" @@ -75,53 +109,53 @@ end create_table "import_reports", force: :cascade do |t| + t.datetime "completed_at" + t.datetime "created_at", null: false + t.json "error_details" t.string "import_type", null: false t.datetime "started_at" - t.datetime "completed_at" + t.string "status", default: "pending" t.json "summary_stats" t.json "unmatched_files" - t.json "error_details" - t.string "status", default: "pending" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["import_type"], name: "index_import_reports_on_import_type" end create_table "languages", force: :cascade do |t| - t.string "name" t.datetime "created_at", null: false + t.string "name" t.datetime "updated_at", null: false end create_table "providers", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "file_name_prefix" t.string "name" + t.integer "old_id" t.string "provider_type" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "old_id" - t.string "file_name_prefix" t.index ["old_id"], name: "index_providers_on_old_id", unique: true end create_table "regions", force: :cascade do |t| - t.string "name" t.datetime "created_at", null: false + t.string "name" t.datetime "updated_at", null: false end create_table "sessions", force: :cascade do |t| - t.bigint "user_id", null: false - t.string "ip_address" - t.string "user_agent" t.datetime "created_at", null: false + t.string "ip_address" t.datetime "updated_at", null: false + t.string "user_agent" + t.bigint "user_id", null: false t.index ["user_id"], name: "index_sessions_on_user_id" end create_table "tag_cognates", force: :cascade do |t| - t.bigint "tag_id" t.bigint "cognate_id" t.datetime "created_at", null: false + t.bigint "tag_id" t.datetime "updated_at", null: false t.index ["cognate_id"], name: "index_tag_cognates_on_cognate_id" t.index ["tag_id", "cognate_id"], name: "index_tag_cognates_on_tag_id_and_cognate_id", unique: true @@ -129,13 +163,13 @@ end create_table "taggings", force: :cascade do |t| + t.string "context", limit: 128 + t.datetime "created_at", precision: nil t.bigint "tag_id" - t.string "taggable_type" t.bigint "taggable_id" - t.string "tagger_type" + t.string "taggable_type" t.bigint "tagger_id" - t.string "context", limit: 128 - t.datetime "created_at", precision: nil + t.string "tagger_type" t.index ["context"], name: "index_taggings_on_context" t.index ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], name: "taggings_idx", unique: true t.index ["tag_id"], name: "index_taggings_on_tag_id" @@ -150,26 +184,26 @@ end create_table "tags", force: :cascade do |t| - t.string "name" t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "name" t.integer "taggings_count", default: 0 + t.datetime "updated_at", null: false t.index ["name"], name: "index_tags_on_name", unique: true end create_table "topics", force: :cascade do |t| - t.bigint "provider_id" - t.bigint "language_id" - t.string "title", null: false - t.text "description" - t.integer "state", default: 0, null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.text "description" + t.string "document_prefix" + t.bigint "language_id" t.integer "old_id" - t.string "uid" + t.bigint "provider_id" t.datetime "published_at", default: -> { "CURRENT_TIMESTAMP" }, null: false t.boolean "shadow_copy", default: false, null: false - t.string "document_prefix" + t.integer "state", default: 0, null: false + t.string "title", null: false + t.string "uid" + t.datetime "updated_at", null: false t.index ["language_id"], name: "index_topics_on_language_id" t.index ["old_id"], name: "index_topics_on_old_id", unique: true t.index ["provider_id"], name: "index_topics_on_provider_id" @@ -177,16 +211,22 @@ end create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false t.string "email", null: false - t.string "password_digest", null: false t.boolean "is_admin", default: false, null: false - t.datetime "created_at", null: false + t.string "password_digest", null: false t.datetime "updated_at", null: false t.index ["email"], name: "index_users_on_email", unique: true end add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "beacon_providers", "beacons" + add_foreign_key "beacon_providers", "providers" + add_foreign_key "beacon_topics", "beacons" + add_foreign_key "beacon_topics", "topics" + add_foreign_key "beacons", "languages" + add_foreign_key "beacons", "regions" add_foreign_key "import_errors", "import_reports" add_foreign_key "sessions", "users" add_foreign_key "tag_cognates", "tags" diff --git a/spec/factories/beacon_providers.rb b/spec/factories/beacon_providers.rb new file mode 100644 index 00000000..43567fe1 --- /dev/null +++ b/spec/factories/beacon_providers.rb @@ -0,0 +1,28 @@ +# == Schema Information +# +# Table name: beacon_providers +# Database name: primary +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# beacon_id :bigint not null +# provider_id :bigint not null +# +# Indexes +# +# index_beacon_providers_on_beacon_id (beacon_id) +# index_beacon_providers_on_beacon_id_and_provider_id (beacon_id,provider_id) UNIQUE +# index_beacon_providers_on_provider_id (provider_id) +# +# Foreign Keys +# +# fk_rails_... (beacon_id => beacons.id) +# fk_rails_... (provider_id => providers.id) +# +FactoryBot.define do + factory :beacon_provider do + association :beacon + association :provider + end +end diff --git a/spec/factories/beacon_topics.rb b/spec/factories/beacon_topics.rb new file mode 100644 index 00000000..8c16079c --- /dev/null +++ b/spec/factories/beacon_topics.rb @@ -0,0 +1,28 @@ +# == Schema Information +# +# Table name: beacon_topics +# Database name: primary +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# beacon_id :bigint not null +# topic_id :bigint not null +# +# Indexes +# +# index_beacon_topics_on_beacon_id (beacon_id) +# index_beacon_topics_on_beacon_id_and_topic_id (beacon_id,topic_id) UNIQUE +# index_beacon_topics_on_topic_id (topic_id) +# +# Foreign Keys +# +# fk_rails_... (beacon_id => beacons.id) +# fk_rails_... (topic_id => topics.id) +# +FactoryBot.define do + factory :beacon_topic do + association :beacon + association :topic + end +end diff --git a/spec/factories/beacons.rb b/spec/factories/beacons.rb new file mode 100644 index 00000000..41753b49 --- /dev/null +++ b/spec/factories/beacons.rb @@ -0,0 +1,59 @@ +# == Schema Information +# +# Table name: beacons +# Database name: primary +# +# id :bigint not null, primary key +# api_key_digest :string not null +# api_key_prefix :string not null +# name :string not null +# revoked_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# language_id :bigint not null +# region_id :bigint not null +# +# Indexes +# +# index_beacons_on_api_key_digest (api_key_digest) UNIQUE +# index_beacons_on_language_id (language_id) +# index_beacons_on_region_id (region_id) +# +# Foreign Keys +# +# fk_rails_... (language_id => languages.id) +# fk_rails_... (region_id => regions.id) +# +FactoryBot.define do + factory :beacon do + sequence(:name) { |n| "Beacon #{n}" } + association :language + association :region + api_key_digest { OpenSSL::Digest::SHA256.hexdigest(SecureRandom.hex(16)) } + api_key_prefix { SecureRandom.hex(4) } + + trait :revoked do + revoked_at { Time.current } + end + + trait :with_providers do + transient do + provider_count { 2 } + end + + after(:create) do |beacon, evaluator| + create_list(:beacon_provider, evaluator.provider_count, beacon: beacon) + end + end + + trait :with_topics do + transient do + topic_count { 2 } + end + + after(:create) do |beacon, evaluator| + create_list(:beacon_topic, evaluator.topic_count, beacon: beacon) + end + end + end +end diff --git a/spec/factories/languages.rb b/spec/factories/languages.rb index 84ad763e..47e3df87 100644 --- a/spec/factories/languages.rb +++ b/spec/factories/languages.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: languages +# Database name: primary # # id :bigint not null, primary key # name :string diff --git a/spec/factories/providers.rb b/spec/factories/providers.rb index 59480edf..4bd80ca2 100644 --- a/spec/factories/providers.rb +++ b/spec/factories/providers.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: providers +# Database name: primary # # id :bigint not null, primary key # file_name_prefix :string diff --git a/spec/factories/regions.rb b/spec/factories/regions.rb index 40317e8b..9c0c7753 100644 --- a/spec/factories/regions.rb +++ b/spec/factories/regions.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: regions +# Database name: primary # # id :bigint not null, primary key # name :string diff --git a/spec/factories/sessions.rb b/spec/factories/sessions.rb index 3c680336..5bc235ea 100644 --- a/spec/factories/sessions.rb +++ b/spec/factories/sessions.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: sessions +# Database name: primary # # id :bigint not null, primary key # ip_address :string diff --git a/spec/factories/tag_cognates.rb b/spec/factories/tag_cognates.rb index 4b0b097e..a147a007 100644 --- a/spec/factories/tag_cognates.rb +++ b/spec/factories/tag_cognates.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: tag_cognates +# Database name: primary # # id :bigint not null, primary key # created_at :datetime not null diff --git a/spec/factories/tags.rb b/spec/factories/tags.rb index 4e1b100b..c6dc5829 100644 --- a/spec/factories/tags.rb +++ b/spec/factories/tags.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: tags +# Database name: primary # # id :bigint not null, primary key # name :string diff --git a/spec/factories/topics.rb b/spec/factories/topics.rb index f385b2db..6efbdd19 100644 --- a/spec/factories/topics.rb +++ b/spec/factories/topics.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: topics +# Database name: primary # # id :bigint not null, primary key # description :text diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 95310262..d6f44e61 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: users +# Database name: primary # # id :bigint not null, primary key # email :string not null diff --git a/spec/models/beacon_provider_spec.rb b/spec/models/beacon_provider_spec.rb new file mode 100644 index 00000000..0c282236 --- /dev/null +++ b/spec/models/beacon_provider_spec.rb @@ -0,0 +1,30 @@ +# == Schema Information +# +# Table name: beacon_providers +# Database name: primary +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# beacon_id :bigint not null +# provider_id :bigint not null +# +# Indexes +# +# index_beacon_providers_on_beacon_id (beacon_id) +# index_beacon_providers_on_beacon_id_and_provider_id (beacon_id,provider_id) UNIQUE +# index_beacon_providers_on_provider_id (provider_id) +# +# Foreign Keys +# +# fk_rails_... (beacon_id => beacons.id) +# fk_rails_... (provider_id => providers.id) +# +require "rails_helper" + +RSpec.describe BeaconProvider, type: :model do + describe "associations" do + it { is_expected.to belong_to(:beacon) } + it { is_expected.to belong_to(:provider) } + end +end diff --git a/spec/models/beacon_spec.rb b/spec/models/beacon_spec.rb new file mode 100644 index 00000000..f397a629 --- /dev/null +++ b/spec/models/beacon_spec.rb @@ -0,0 +1,90 @@ +# == Schema Information +# +# Table name: beacons +# Database name: primary +# +# id :bigint not null, primary key +# api_key_digest :string not null +# api_key_prefix :string not null +# name :string not null +# revoked_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# language_id :bigint not null +# region_id :bigint not null +# +# Indexes +# +# index_beacons_on_api_key_digest (api_key_digest) UNIQUE +# index_beacons_on_language_id (language_id) +# index_beacons_on_region_id (region_id) +# +# Foreign Keys +# +# fk_rails_... (language_id => languages.id) +# fk_rails_... (region_id => regions.id) +# +require "rails_helper" + +RSpec.describe Beacon, type: :model do + subject { create(:beacon) } + + describe "validations" do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:api_key_digest) } + it { is_expected.to validate_uniqueness_of(:api_key_digest) } + it { is_expected.to validate_presence_of(:api_key_prefix) } + end + + describe "associations" do + it { is_expected.to belong_to(:language) } + it { is_expected.to belong_to(:region) } + it { is_expected.to have_many(:beacon_providers).dependent(:destroy) } + it { is_expected.to have_many(:providers).through(:beacon_providers) } + it { is_expected.to have_many(:beacon_topics).dependent(:destroy) } + it { is_expected.to have_many(:topics).through(:beacon_topics) } + end + + describe "scopes" do + let!(:active_beacon) { create(:beacon) } + let!(:revoked_beacon) { create(:beacon, :revoked) } + + describe ".active" do + it "returns only active beacons" do + expect(described_class.active).to contain_exactly(active_beacon) + end + end + + describe ".revoked" do + it "returns only revoked beacons" do + expect(described_class.revoked).to contain_exactly(revoked_beacon) + end + end + end + + describe "#revoke!" do + include ActiveSupport::Testing::TimeHelpers + + it "sets revoked_at to the current time" do + beacon = create(:beacon) + now = Time.current + + travel_to(now) do + beacon.revoke! + expect(beacon.revoked_at).to be_within(1.second).of(now) + end + end + end + + describe "#revoked?" do + it "returns false for active beacons" do + beacon = create(:beacon) + expect(beacon).not_to be_revoked + end + + it "returns true for revoked beacons" do + beacon = create(:beacon, :revoked) + expect(beacon).to be_revoked + end + end +end diff --git a/spec/models/beacon_topic_spec.rb b/spec/models/beacon_topic_spec.rb new file mode 100644 index 00000000..1e8a90b1 --- /dev/null +++ b/spec/models/beacon_topic_spec.rb @@ -0,0 +1,30 @@ +# == Schema Information +# +# Table name: beacon_topics +# Database name: primary +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# beacon_id :bigint not null +# topic_id :bigint not null +# +# Indexes +# +# index_beacon_topics_on_beacon_id (beacon_id) +# index_beacon_topics_on_beacon_id_and_topic_id (beacon_id,topic_id) UNIQUE +# index_beacon_topics_on_topic_id (topic_id) +# +# Foreign Keys +# +# fk_rails_... (beacon_id => beacons.id) +# fk_rails_... (topic_id => topics.id) +# +require "rails_helper" + +RSpec.describe BeaconTopic, type: :model do + describe "associations" do + it { is_expected.to belong_to(:beacon) } + it { is_expected.to belong_to(:topic) } + end +end diff --git a/spec/models/language_spec.rb b/spec/models/language_spec.rb index 83664070..fff27071 100644 --- a/spec/models/language_spec.rb +++ b/spec/models/language_spec.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: languages +# Database name: primary # # id :bigint not null, primary key # name :string diff --git a/spec/models/region_spec.rb b/spec/models/region_spec.rb index 18082715..1bc15875 100644 --- a/spec/models/region_spec.rb +++ b/spec/models/region_spec.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: regions +# Database name: primary # # id :bigint not null, primary key # name :string diff --git a/spec/models/tag_cognate_spec.rb b/spec/models/tag_cognate_spec.rb index 788a50a8..c383f4f0 100644 --- a/spec/models/tag_cognate_spec.rb +++ b/spec/models/tag_cognate_spec.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: tag_cognates +# Database name: primary # # id :bigint not null, primary key # created_at :datetime not null diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index fbf78cbf..a580511f 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: tags +# Database name: primary # # id :bigint not null, primary key # name :string diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index c77ff687..34c4aa9a 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: topics +# Database name: primary # # id :bigint not null, primary key # description :text diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 3b7420e3..697e8073 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: users +# Database name: primary # # id :bigint not null, primary key # email :string not null diff --git a/spec/requests/api/v1/beacon_authentication_spec.rb b/spec/requests/api/v1/beacon_authentication_spec.rb new file mode 100644 index 00000000..f74e24a3 --- /dev/null +++ b/spec/requests/api/v1/beacon_authentication_spec.rb @@ -0,0 +1,48 @@ +require "rails_helper" + +RSpec.describe "Beacon Authentication", type: :request do + describe "GET /api/v1/beacons/status" do + it "returns beacon info with a valid API key" do + beacon, raw_key = create_beacon_with_key(name: "Test Beacon") + + get "/api/v1/beacons/status", headers: beacon_auth_headers(raw_key) + + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body["status"]).to eq("ok") + expect(body["beacon"]["id"]).to eq(beacon.id) + expect(body["beacon"]["name"]).to eq("Test Beacon") + end + + it "returns unauthorized when no authorization header is present" do + get "/api/v1/beacons/status" + + expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body["error"]).to eq("Missing authorization header") + end + + it "returns unauthorized with an invalid API key" do + get "/api/v1/beacons/status", headers: beacon_auth_headers("sk_live_invalid_key_000000000") + + expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body["error"]).to eq("Invalid API key") + end + + it "returns unauthorized when the beacon is revoked" do + beacon, raw_key = create_beacon_with_key + beacon.revoke! + + get "/api/v1/beacons/status", headers: beacon_auth_headers(raw_key) + + expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body["error"]).to eq("API key has been revoked") + end + + it "returns unauthorized with a malformed authorization header" do + get "/api/v1/beacons/status", headers: { "Authorization" => "Token sk_live_something" } + + expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body["error"]).to eq("Missing authorization header") + end + end +end diff --git a/spec/services/beacons/api_key_generator_spec.rb b/spec/services/beacons/api_key_generator_spec.rb new file mode 100644 index 00000000..761041d4 --- /dev/null +++ b/spec/services/beacons/api_key_generator_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe Beacons::ApiKeyGenerator do + subject(:generator) { described_class.new } + + describe "#call" do + it "returns a result with raw_key, digest, and prefix" do + result = generator.call + + expect(result).to respond_to(:raw_key, :digest, :prefix) + end + + it "generates a key with the sk_live_ prefix" do + result = generator.call + + expect(result.raw_key).to start_with("sk_live_") + end + + it "generates a 40-character raw key" do + result = generator.call + + # "sk_live_" (8 chars) + 32 hex chars = 40 chars + expect(result.raw_key.length).to eq(40) + end + + it "computes a valid SHA256 digest of the raw key" do + result = generator.call + expected_digest = OpenSSL::Digest::SHA256.hexdigest(result.raw_key) + + expect(result.digest).to eq(expected_digest) + end + + it "extracts the first 8 characters after the prefix" do + result = generator.call + expected_prefix = result.raw_key.delete_prefix("sk_live_")[0, 8] + + expect(result.prefix).to eq(expected_prefix) + end + + it "generates unique keys on each call" do + results = Array.new(5) { generator.call } + raw_keys = results.map(&:raw_key) + + expect(raw_keys.uniq.length).to eq(5) + end + end +end diff --git a/spec/services/beacons/creator_spec.rb b/spec/services/beacons/creator_spec.rb new file mode 100644 index 00000000..cc409388 --- /dev/null +++ b/spec/services/beacons/creator_spec.rb @@ -0,0 +1,51 @@ +require "rails_helper" + +RSpec.describe Beacons::Creator do + describe "#call" do + let(:language) { create(:language) } + let(:region) { create(:region) } + + it "creates a beacon and returns the raw key" do + creator = described_class.new + success, beacon, raw_key = creator.call(name: "Test Beacon", language: language, region: region) + + expect(success).to be true + expect(beacon).to be_persisted + expect(raw_key).to start_with("sk_live_") + end + + it "sets the api_key_digest and api_key_prefix on the beacon" do + creator = described_class.new + _, beacon, raw_key = creator.call(name: "Test Beacon", language: language, region: region) + expected_digest = OpenSSL::Digest::SHA256.hexdigest(raw_key) + + expect(beacon.api_key_digest).to eq(expected_digest) + expect(beacon.api_key_prefix).to eq(raw_key.delete_prefix("sk_live_")[0, 8]) + end + + it "returns failure when params are invalid" do + creator = described_class.new + success, beacon, raw_key = creator.call(name: "", language: language, region: region) + + expect(success).to be false + expect(beacon).not_to be_persisted + expect(raw_key).to be_nil + end + + it "accepts a custom key generator via dependency injection" do + fake_result = Beacons::ApiKeyGenerator::Result.new( + raw_key: "sk_live_custom1234567890abcdef", + digest: "fake_digest_value", + prefix: "custom12", + ) + fake_generator = instance_double(Beacons::ApiKeyGenerator, call: fake_result) + creator = described_class.new(key_generator: fake_generator) + + success, beacon, raw_key = creator.call(name: "Test Beacon", language: language, region: region) + + expect(success).to be true + expect(beacon.api_key_digest).to eq("fake_digest_value") + expect(raw_key).to eq("sk_live_custom1234567890abcdef") + end + end +end diff --git a/spec/services/beacons/key_regenerator_spec.rb b/spec/services/beacons/key_regenerator_spec.rb new file mode 100644 index 00000000..e6606289 --- /dev/null +++ b/spec/services/beacons/key_regenerator_spec.rb @@ -0,0 +1,43 @@ +require "rails_helper" + +RSpec.describe Beacons::KeyRegenerator do + describe "#call" do + it "generates a new key for the beacon" do + beacon = create(:beacon) + old_digest = beacon.api_key_digest + + regenerator = described_class.new + _, raw_key = regenerator.call(beacon) + + expect(beacon.reload.api_key_digest).not_to eq(old_digest) + expect(raw_key).to start_with("sk_live_") + expect(beacon.api_key_digest).to eq(OpenSSL::Digest::SHA256.hexdigest(raw_key)) + end + + it "clears revoked_at on a revoked beacon" do + beacon = create(:beacon, :revoked) + expect(beacon.revoked_at).to be_present + + regenerator = described_class.new + regenerator.call(beacon) + + expect(beacon.reload.revoked_at).to be_nil + end + + it "accepts a custom key generator via dependency injection" do + beacon = create(:beacon) + fake_result = Beacons::ApiKeyGenerator::Result.new( + raw_key: "sk_live_injected_key_1234abcd", + digest: "injected_digest_value", + prefix: "injected", + ) + fake_generator = instance_double(Beacons::ApiKeyGenerator, call: fake_result) + + regenerator = described_class.new(key_generator: fake_generator) + returned_beacon, raw_key = regenerator.call(beacon) + + expect(returned_beacon.api_key_digest).to eq("injected_digest_value") + expect(raw_key).to eq("sk_live_injected_key_1234abcd") + end + end +end diff --git a/spec/support/beacon_authentication_helpers.rb b/spec/support/beacon_authentication_helpers.rb new file mode 100644 index 00000000..b86c9421 --- /dev/null +++ b/spec/support/beacon_authentication_helpers.rb @@ -0,0 +1,21 @@ +module BeaconAuthenticationHelpers + def beacon_auth_headers(raw_key) + { "Authorization" => "Bearer #{raw_key}" } + end + + def create_beacon_with_key(attributes = {}) + generator = Beacons::ApiKeyGenerator.new + key_result = generator.call + + beacon = create(:beacon, **attributes.merge( + api_key_digest: key_result.digest, + api_key_prefix: key_result.prefix, + )) + + [ beacon, key_result.raw_key ] + end +end + +RSpec.configure do |config| + config.include BeaconAuthenticationHelpers, type: :request +end diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 38096be8..939ba255 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -3,6 +3,7 @@ # == Schema Information # # Table name: users +# Database name: primary # # id :bigint not null, primary key # email :string not null diff --git a/test/models/user_test.rb b/test/models/user_test.rb index da5a534f..8feb3473 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -1,6 +1,7 @@ # == Schema Information # # Table name: users +# Database name: primary # # id :bigint not null, primary key # email :string not null