From 70c6b92c51d773f4ace6c982279370e93911da4f Mon Sep 17 00:00:00 2001 From: dmitrytrager Date: Tue, 3 Feb 2026 11:03:35 +0100 Subject: [PATCH 1/3] Add device management with API key authentication Implement Device model with SHA256-hashed API keys, join tables for provider/topic associations, and Bearer token authentication for the device API. Includes services for key generation, device creation, and key regeneration with soft revocation support. Co-Authored-By: Claude Opus 4.5 --- app/controllers/api/v1/base_controller.rb | 6 + .../api/v1/devices/base_controller.rb | 9 ++ .../api/v1/devices/statuses_controller.rb | 17 +++ .../concerns/api/device_authentication.rb | 35 +++++ app/models/branch.rb | 1 + app/models/contributor.rb | 1 + app/models/current.rb | 1 + app/models/device.rb | 47 +++++++ app/models/device_provider.rb | 26 ++++ app/models/device_topic.rb | 26 ++++ app/models/import_error.rb | 1 + app/models/import_report.rb | 1 + app/models/language.rb | 2 + app/models/provider.rb | 3 + app/models/region.rb | 1 + app/models/session.rb | 1 + app/models/tag.rb | 1 + app/models/tag_cognate.rb | 1 + app/models/topic.rb | 3 + app/models/user.rb | 1 + app/services/devices/api_key_generator.rb | 15 +++ app/services/devices/creator.rb | 25 ++++ app/services/devices/key_regenerator.rb | 21 +++ config/routes.rb | 4 + db/migrate/20260203100000_create_devices.rb | 15 +++ .../20260203100001_create_device_providers.rb | 12 ++ .../20260203100002_create_device_topics.rb | 12 ++ db/queue_schema.rb | 70 +++++----- db/schema.rb | 127 +++++++++++------- spec/factories/device_providers.rb | 28 ++++ spec/factories/device_topics.rb | 28 ++++ spec/factories/devices.rb | 55 ++++++++ spec/factories/languages.rb | 1 + spec/factories/providers.rb | 1 + spec/factories/regions.rb | 1 + spec/factories/sessions.rb | 1 + spec/factories/tag_cognates.rb | 1 + spec/factories/tags.rb | 1 + spec/factories/topics.rb | 1 + spec/factories/users.rb | 1 + spec/models/device_provider_spec.rb | 30 +++++ spec/models/device_spec.rb | 86 ++++++++++++ spec/models/device_topic_spec.rb | 30 +++++ spec/models/language_spec.rb | 1 + spec/models/region_spec.rb | 1 + spec/models/tag_cognate_spec.rb | 1 + spec/models/tag_spec.rb | 1 + spec/models/topic_spec.rb | 1 + spec/models/user_spec.rb | 1 + .../api/v1/device_authentication_spec.rb | 48 +++++++ .../devices/api_key_generator_spec.rb | 47 +++++++ spec/services/devices/creator_spec.rb | 50 +++++++ spec/services/devices/key_regenerator_spec.rb | 43 ++++++ spec/support/device_authentication_helpers.rb | 21 +++ test/fixtures/users.yml | 1 + test/models/user_test.rb | 1 + 56 files changed, 887 insertions(+), 80 deletions(-) create mode 100644 app/controllers/api/v1/base_controller.rb create mode 100644 app/controllers/api/v1/devices/base_controller.rb create mode 100644 app/controllers/api/v1/devices/statuses_controller.rb create mode 100644 app/controllers/concerns/api/device_authentication.rb create mode 100644 app/models/device.rb create mode 100644 app/models/device_provider.rb create mode 100644 app/models/device_topic.rb create mode 100644 app/services/devices/api_key_generator.rb create mode 100644 app/services/devices/creator.rb create mode 100644 app/services/devices/key_regenerator.rb create mode 100644 db/migrate/20260203100000_create_devices.rb create mode 100644 db/migrate/20260203100001_create_device_providers.rb create mode 100644 db/migrate/20260203100002_create_device_topics.rb create mode 100644 spec/factories/device_providers.rb create mode 100644 spec/factories/device_topics.rb create mode 100644 spec/factories/devices.rb create mode 100644 spec/models/device_provider_spec.rb create mode 100644 spec/models/device_spec.rb create mode 100644 spec/models/device_topic_spec.rb create mode 100644 spec/requests/api/v1/device_authentication_spec.rb create mode 100644 spec/services/devices/api_key_generator_spec.rb create mode 100644 spec/services/devices/creator_spec.rb create mode 100644 spec/services/devices/key_regenerator_spec.rb create mode 100644 spec/support/device_authentication_helpers.rb 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/devices/base_controller.rb b/app/controllers/api/v1/devices/base_controller.rb new file mode 100644 index 00000000..3467b52c --- /dev/null +++ b/app/controllers/api/v1/devices/base_controller.rb @@ -0,0 +1,9 @@ +module Api + module V1 + module Devices + class BaseController < Api::V1::BaseController + include Api::DeviceAuthentication + end + end + end +end diff --git a/app/controllers/api/v1/devices/statuses_controller.rb b/app/controllers/api/v1/devices/statuses_controller.rb new file mode 100644 index 00000000..395ab6ff --- /dev/null +++ b/app/controllers/api/v1/devices/statuses_controller.rb @@ -0,0 +1,17 @@ +module Api + module V1 + module Devices + class StatusesController < Devices::BaseController + def show + render json: { + status: "ok", + device: { + id: Current.device.id, + name: Current.device.name, + }, + } + end + end + end + end +end diff --git a/app/controllers/concerns/api/device_authentication.rb b/app/controllers/concerns/api/device_authentication.rb new file mode 100644 index 00000000..a5f0ce98 --- /dev/null +++ b/app/controllers/concerns/api/device_authentication.rb @@ -0,0 +1,35 @@ +module Api + module DeviceAuthentication + extend ActiveSupport::Concern + + included do + before_action :authenticate_device! + end + + private + + def authenticate_device! + token = extract_bearer_token + return render_unauthorized("Missing authorization header") if token.blank? + + digest = OpenSSL::Digest::SHA256.hexdigest(token) + device = Device.find_by(api_key_digest: digest) + + return render_unauthorized("Invalid API key") if device.nil? + return render_unauthorized("API key has been revoked") if device.revoked? + + Current.device = device + 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/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..3e5b9c1e 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,4 +1,5 @@ class Current < ActiveSupport::CurrentAttributes attribute :session + attribute :device delegate :user, to: :session, allow_nil: true end diff --git a/app/models/device.rb b/app/models/device.rb new file mode 100644 index 00000000..5ce4507f --- /dev/null +++ b/app/models/device.rb @@ -0,0 +1,47 @@ +# == Schema Information +# +# Table name: devices +# 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 +# +# Indexes +# +# index_devices_on_api_key_digest (api_key_digest) UNIQUE +# index_devices_on_language_id (language_id) +# +# Foreign Keys +# +# fk_rails_... (language_id => languages.id) +# +class Device < ApplicationRecord + belongs_to :language + + has_many :device_providers, dependent: :destroy + has_many :providers, through: :device_providers + + has_many :device_topics, dependent: :destroy + has_many :topics, through: :device_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/device_provider.rb b/app/models/device_provider.rb new file mode 100644 index 00000000..e2940b87 --- /dev/null +++ b/app/models/device_provider.rb @@ -0,0 +1,26 @@ +# == Schema Information +# +# Table name: device_providers +# Database name: primary +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# device_id :bigint not null +# provider_id :bigint not null +# +# Indexes +# +# index_device_providers_on_device_id (device_id) +# index_device_providers_on_device_id_and_provider_id (device_id,provider_id) UNIQUE +# index_device_providers_on_provider_id (provider_id) +# +# Foreign Keys +# +# fk_rails_... (device_id => devices.id) +# fk_rails_... (provider_id => providers.id) +# +class DeviceProvider < ApplicationRecord + belongs_to :device + belongs_to :provider +end diff --git a/app/models/device_topic.rb b/app/models/device_topic.rb new file mode 100644 index 00000000..f262853c --- /dev/null +++ b/app/models/device_topic.rb @@ -0,0 +1,26 @@ +# == Schema Information +# +# Table name: device_topics +# Database name: primary +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# device_id :bigint not null +# topic_id :bigint not null +# +# Indexes +# +# index_device_topics_on_device_id (device_id) +# index_device_topics_on_device_id_and_topic_id (device_id,topic_id) UNIQUE +# index_device_topics_on_topic_id (topic_id) +# +# Foreign Keys +# +# fk_rails_... (device_id => devices.id) +# fk_rails_... (topic_id => topics.id) +# +class DeviceTopic < ApplicationRecord + belongs_to :device + belongs_to :topic +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..c0b7ce6b 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 :devices, 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..eb386ea7 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 :device_providers, dependent: :destroy + has_many :devices, through: :device_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..79f018a1 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 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..56df24e2 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 :device_topics, dependent: :destroy + has_many :devices, through: :device_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/devices/api_key_generator.rb b/app/services/devices/api_key_generator.rb new file mode 100644 index 00000000..ec1f43e9 --- /dev/null +++ b/app/services/devices/api_key_generator.rb @@ -0,0 +1,15 @@ +module Devices + 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/devices/creator.rb b/app/services/devices/creator.rb new file mode 100644 index 00000000..4842b42a --- /dev/null +++ b/app/services/devices/creator.rb @@ -0,0 +1,25 @@ +module Devices + 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 + + device = Device.new( + **params, + api_key_digest: key_result.digest, + api_key_prefix: key_result.prefix, + ) + + if device.save + [ true, device, key_result.raw_key ] + else + [ false, device, nil ] + end + end + end +end diff --git a/app/services/devices/key_regenerator.rb b/app/services/devices/key_regenerator.rb new file mode 100644 index 00000000..4386f202 --- /dev/null +++ b/app/services/devices/key_regenerator.rb @@ -0,0 +1,21 @@ +module Devices + class KeyRegenerator + attr_reader :key_generator + + def initialize(key_generator: ApiKeyGenerator.new) + @key_generator = key_generator + end + + def call(device) + key_result = key_generator.call + + device.update!( + api_key_digest: key_result.digest, + api_key_prefix: key_result.prefix, + revoked_at: nil, + ) + + [ device, key_result.raw_key ] + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 24879cbf..ef8a748c 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 :devices 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/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..c7db3bcd 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_100002) 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 @@ -43,31 +43,63 @@ 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 "device_providers", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "device_id", null: false + t.bigint "provider_id", null: false + t.datetime "updated_at", null: false + t.index ["device_id", "provider_id"], name: "index_device_providers_on_device_id_and_provider_id", unique: true + t.index ["device_id"], name: "index_device_providers_on_device_id" + t.index ["provider_id"], name: "index_device_providers_on_provider_id" + end + + create_table "device_topics", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "device_id", null: false + t.bigint "topic_id", null: false + t.datetime "updated_at", null: false + t.index ["device_id", "topic_id"], name: "index_device_topics_on_device_id_and_topic_id", unique: true + t.index ["device_id"], name: "index_device_topics_on_device_id" + t.index ["topic_id"], name: "index_device_topics_on_topic_id" + end + + create_table "devices", 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.datetime "revoked_at" + t.datetime "updated_at", null: false + t.index ["api_key_digest"], name: "index_devices_on_api_key_digest", unique: true + t.index ["language_id"], name: "index_devices_on_language_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 +107,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 +161,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 +182,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 +209,21 @@ 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 "device_providers", "devices" + add_foreign_key "device_providers", "providers" + add_foreign_key "device_topics", "devices" + add_foreign_key "device_topics", "topics" + add_foreign_key "devices", "languages" add_foreign_key "import_errors", "import_reports" add_foreign_key "sessions", "users" add_foreign_key "tag_cognates", "tags" diff --git a/spec/factories/device_providers.rb b/spec/factories/device_providers.rb new file mode 100644 index 00000000..777f4881 --- /dev/null +++ b/spec/factories/device_providers.rb @@ -0,0 +1,28 @@ +# == Schema Information +# +# Table name: device_providers +# Database name: primary +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# device_id :bigint not null +# provider_id :bigint not null +# +# Indexes +# +# index_device_providers_on_device_id (device_id) +# index_device_providers_on_device_id_and_provider_id (device_id,provider_id) UNIQUE +# index_device_providers_on_provider_id (provider_id) +# +# Foreign Keys +# +# fk_rails_... (device_id => devices.id) +# fk_rails_... (provider_id => providers.id) +# +FactoryBot.define do + factory :device_provider do + association :device + association :provider + end +end diff --git a/spec/factories/device_topics.rb b/spec/factories/device_topics.rb new file mode 100644 index 00000000..d1017e2b --- /dev/null +++ b/spec/factories/device_topics.rb @@ -0,0 +1,28 @@ +# == Schema Information +# +# Table name: device_topics +# Database name: primary +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# device_id :bigint not null +# topic_id :bigint not null +# +# Indexes +# +# index_device_topics_on_device_id (device_id) +# index_device_topics_on_device_id_and_topic_id (device_id,topic_id) UNIQUE +# index_device_topics_on_topic_id (topic_id) +# +# Foreign Keys +# +# fk_rails_... (device_id => devices.id) +# fk_rails_... (topic_id => topics.id) +# +FactoryBot.define do + factory :device_topic do + association :device + association :topic + end +end diff --git a/spec/factories/devices.rb b/spec/factories/devices.rb new file mode 100644 index 00000000..629012a8 --- /dev/null +++ b/spec/factories/devices.rb @@ -0,0 +1,55 @@ +# == Schema Information +# +# Table name: devices +# 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 +# +# Indexes +# +# index_devices_on_api_key_digest (api_key_digest) UNIQUE +# index_devices_on_language_id (language_id) +# +# Foreign Keys +# +# fk_rails_... (language_id => languages.id) +# +FactoryBot.define do + factory :device do + sequence(:name) { |n| "Device #{n}" } + association :language + 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 |device, evaluator| + create_list(:device_provider, evaluator.provider_count, device: device) + end + end + + trait :with_topics do + transient do + topic_count { 2 } + end + + after(:create) do |device, evaluator| + create_list(:device_topic, evaluator.topic_count, device: device) + 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/device_provider_spec.rb b/spec/models/device_provider_spec.rb new file mode 100644 index 00000000..27215c4a --- /dev/null +++ b/spec/models/device_provider_spec.rb @@ -0,0 +1,30 @@ +# == Schema Information +# +# Table name: device_providers +# Database name: primary +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# device_id :bigint not null +# provider_id :bigint not null +# +# Indexes +# +# index_device_providers_on_device_id (device_id) +# index_device_providers_on_device_id_and_provider_id (device_id,provider_id) UNIQUE +# index_device_providers_on_provider_id (provider_id) +# +# Foreign Keys +# +# fk_rails_... (device_id => devices.id) +# fk_rails_... (provider_id => providers.id) +# +require "rails_helper" + +RSpec.describe DeviceProvider, type: :model do + describe "associations" do + it { is_expected.to belong_to(:device) } + it { is_expected.to belong_to(:provider) } + end +end diff --git a/spec/models/device_spec.rb b/spec/models/device_spec.rb new file mode 100644 index 00000000..214bdd64 --- /dev/null +++ b/spec/models/device_spec.rb @@ -0,0 +1,86 @@ +# == Schema Information +# +# Table name: devices +# 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 +# +# Indexes +# +# index_devices_on_api_key_digest (api_key_digest) UNIQUE +# index_devices_on_language_id (language_id) +# +# Foreign Keys +# +# fk_rails_... (language_id => languages.id) +# +require "rails_helper" + +RSpec.describe Device, type: :model do + subject { create(:device) } + + 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 have_many(:device_providers).dependent(:destroy) } + it { is_expected.to have_many(:providers).through(:device_providers) } + it { is_expected.to have_many(:device_topics).dependent(:destroy) } + it { is_expected.to have_many(:topics).through(:device_topics) } + end + + describe "scopes" do + let!(:active_device) { create(:device) } + let!(:revoked_device) { create(:device, :revoked) } + + describe ".active" do + it "returns only active devices" do + expect(described_class.active).to contain_exactly(active_device) + end + end + + describe ".revoked" do + it "returns only revoked devices" do + expect(described_class.revoked).to contain_exactly(revoked_device) + end + end + end + + describe "#revoke!" do + include ActiveSupport::Testing::TimeHelpers + + it "sets revoked_at to the current time" do + device = create(:device) + now = Time.current + + travel_to(now) do + device.revoke! + expect(device.revoked_at).to be_within(1.second).of(now) + end + end + end + + describe "#revoked?" do + it "returns false for active devices" do + device = create(:device) + expect(device).not_to be_revoked + end + + it "returns true for revoked devices" do + device = create(:device, :revoked) + expect(device).to be_revoked + end + end +end diff --git a/spec/models/device_topic_spec.rb b/spec/models/device_topic_spec.rb new file mode 100644 index 00000000..3d2ef0c7 --- /dev/null +++ b/spec/models/device_topic_spec.rb @@ -0,0 +1,30 @@ +# == Schema Information +# +# Table name: device_topics +# Database name: primary +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# device_id :bigint not null +# topic_id :bigint not null +# +# Indexes +# +# index_device_topics_on_device_id (device_id) +# index_device_topics_on_device_id_and_topic_id (device_id,topic_id) UNIQUE +# index_device_topics_on_topic_id (topic_id) +# +# Foreign Keys +# +# fk_rails_... (device_id => devices.id) +# fk_rails_... (topic_id => topics.id) +# +require "rails_helper" + +RSpec.describe DeviceTopic, type: :model do + describe "associations" do + it { is_expected.to belong_to(:device) } + 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/device_authentication_spec.rb b/spec/requests/api/v1/device_authentication_spec.rb new file mode 100644 index 00000000..f3008451 --- /dev/null +++ b/spec/requests/api/v1/device_authentication_spec.rb @@ -0,0 +1,48 @@ +require "rails_helper" + +RSpec.describe "Device Authentication", type: :request do + describe "GET /api/v1/devices/status" do + it "returns device info with a valid API key" do + device, raw_key = create_device_with_key(name: "Test Device") + + get "/api/v1/devices/status", headers: device_auth_headers(raw_key) + + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body["status"]).to eq("ok") + expect(body["device"]["id"]).to eq(device.id) + expect(body["device"]["name"]).to eq("Test Device") + end + + it "returns unauthorized when no authorization header is present" do + get "/api/v1/devices/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/devices/status", headers: device_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 device is revoked" do + device, raw_key = create_device_with_key + device.revoke! + + get "/api/v1/devices/status", headers: device_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/devices/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/devices/api_key_generator_spec.rb b/spec/services/devices/api_key_generator_spec.rb new file mode 100644 index 00000000..e6473f46 --- /dev/null +++ b/spec/services/devices/api_key_generator_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe Devices::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/devices/creator_spec.rb b/spec/services/devices/creator_spec.rb new file mode 100644 index 00000000..dfa1c77d --- /dev/null +++ b/spec/services/devices/creator_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +RSpec.describe Devices::Creator do + describe "#call" do + let(:language) { create(:language) } + + it "creates a device and returns the raw key" do + creator = described_class.new + success, device, raw_key = creator.call(name: "Test Device", language: language) + + expect(success).to be true + expect(device).to be_persisted + expect(raw_key).to start_with("sk_live_") + end + + it "sets the api_key_digest and api_key_prefix on the device" do + creator = described_class.new + _, device, raw_key = creator.call(name: "Test Device", language: language) + expected_digest = OpenSSL::Digest::SHA256.hexdigest(raw_key) + + expect(device.api_key_digest).to eq(expected_digest) + expect(device.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, device, raw_key = creator.call(name: "", language: language) + + expect(success).to be false + expect(device).not_to be_persisted + expect(raw_key).to be_nil + end + + it "accepts a custom key generator via dependency injection" do + fake_result = Devices::ApiKeyGenerator::Result.new( + raw_key: "sk_live_custom1234567890abcdef", + digest: "fake_digest_value", + prefix: "custom12", + ) + fake_generator = instance_double(Devices::ApiKeyGenerator, call: fake_result) + creator = described_class.new(key_generator: fake_generator) + + success, device, raw_key = creator.call(name: "Test Device", language: language) + + expect(success).to be true + expect(device.api_key_digest).to eq("fake_digest_value") + expect(raw_key).to eq("sk_live_custom1234567890abcdef") + end + end +end diff --git a/spec/services/devices/key_regenerator_spec.rb b/spec/services/devices/key_regenerator_spec.rb new file mode 100644 index 00000000..4496ac78 --- /dev/null +++ b/spec/services/devices/key_regenerator_spec.rb @@ -0,0 +1,43 @@ +require "rails_helper" + +RSpec.describe Devices::KeyRegenerator do + describe "#call" do + it "generates a new key for the device" do + device = create(:device) + old_digest = device.api_key_digest + + regenerator = described_class.new + _, raw_key = regenerator.call(device) + + expect(device.reload.api_key_digest).not_to eq(old_digest) + expect(raw_key).to start_with("sk_live_") + expect(device.api_key_digest).to eq(OpenSSL::Digest::SHA256.hexdigest(raw_key)) + end + + it "clears revoked_at on a revoked device" do + device = create(:device, :revoked) + expect(device.revoked_at).to be_present + + regenerator = described_class.new + regenerator.call(device) + + expect(device.reload.revoked_at).to be_nil + end + + it "accepts a custom key generator via dependency injection" do + device = create(:device) + fake_result = Devices::ApiKeyGenerator::Result.new( + raw_key: "sk_live_injected_key_1234abcd", + digest: "injected_digest_value", + prefix: "injected", + ) + fake_generator = instance_double(Devices::ApiKeyGenerator, call: fake_result) + + regenerator = described_class.new(key_generator: fake_generator) + returned_device, raw_key = regenerator.call(device) + + expect(returned_device.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/device_authentication_helpers.rb b/spec/support/device_authentication_helpers.rb new file mode 100644 index 00000000..1190d7eb --- /dev/null +++ b/spec/support/device_authentication_helpers.rb @@ -0,0 +1,21 @@ +module DeviceAuthenticationHelpers + def device_auth_headers(raw_key) + { "Authorization" => "Bearer #{raw_key}" } + end + + def create_device_with_key(attributes = {}) + generator = Devices::ApiKeyGenerator.new + key_result = generator.call + + device = create(:device, **attributes.merge( + api_key_digest: key_result.digest, + api_key_prefix: key_result.prefix, + )) + + [ device, key_result.raw_key ] + end +end + +RSpec.configure do |config| + config.include DeviceAuthenticationHelpers, 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 From a73cb67ecb2cd15191f52fadaeaec806f64bef8f Mon Sep 17 00:00:00 2001 From: dmitrytrager Date: Tue, 3 Feb 2026 11:43:47 +0100 Subject: [PATCH 2/3] Add region association to devices Each device now belongs to a region in addition to a language, allowing location-based scoping of device content delivery. Co-Authored-By: Claude Opus 4.5 --- app/models/device.rb | 4 ++++ app/models/region.rb | 1 + db/migrate/20260203100003_add_region_to_devices.rb | 5 +++++ db/schema.rb | 5 ++++- spec/factories/devices.rb | 4 ++++ spec/models/device_spec.rb | 4 ++++ spec/services/devices/creator_spec.rb | 9 +++++---- 7 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 db/migrate/20260203100003_add_region_to_devices.rb diff --git a/app/models/device.rb b/app/models/device.rb index 5ce4507f..42a4eb58 100644 --- a/app/models/device.rb +++ b/app/models/device.rb @@ -11,18 +11,22 @@ # created_at :datetime not null # updated_at :datetime not null # language_id :bigint not null +# region_id :bigint not null # # Indexes # # index_devices_on_api_key_digest (api_key_digest) UNIQUE # index_devices_on_language_id (language_id) +# index_devices_on_region_id (region_id) # # Foreign Keys # # fk_rails_... (language_id => languages.id) +# fk_rails_... (region_id => regions.id) # class Device < ApplicationRecord belongs_to :language + belongs_to :region has_many :device_providers, dependent: :destroy has_many :providers, through: :device_providers diff --git a/app/models/region.rb b/app/models/region.rb index 79f018a1..84c88cb4 100644 --- a/app/models/region.rb +++ b/app/models/region.rb @@ -11,6 +11,7 @@ class Region < ApplicationRecord has_many :branches, dependent: :destroy has_many :providers, through: :branches + has_many :devices, dependent: :destroy validates :name, presence: true 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/schema.rb b/db/schema.rb index c7db3bcd..5475fd53 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_03_100002) do +ActiveRecord::Schema[8.1].define(version: 2026_02_03_100003) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -86,10 +86,12 @@ 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_devices_on_api_key_digest", unique: true t.index ["language_id"], name: "index_devices_on_language_id" + t.index ["region_id"], name: "index_devices_on_region_id" end create_table "import_errors", force: :cascade do |t| @@ -224,6 +226,7 @@ add_foreign_key "device_topics", "devices" add_foreign_key "device_topics", "topics" add_foreign_key "devices", "languages" + add_foreign_key "devices", "regions" add_foreign_key "import_errors", "import_reports" add_foreign_key "sessions", "users" add_foreign_key "tag_cognates", "tags" diff --git a/spec/factories/devices.rb b/spec/factories/devices.rb index 629012a8..6ec95b2a 100644 --- a/spec/factories/devices.rb +++ b/spec/factories/devices.rb @@ -11,20 +11,24 @@ # created_at :datetime not null # updated_at :datetime not null # language_id :bigint not null +# region_id :bigint not null # # Indexes # # index_devices_on_api_key_digest (api_key_digest) UNIQUE # index_devices_on_language_id (language_id) +# index_devices_on_region_id (region_id) # # Foreign Keys # # fk_rails_... (language_id => languages.id) +# fk_rails_... (region_id => regions.id) # FactoryBot.define do factory :device do sequence(:name) { |n| "Device #{n}" } association :language + association :region api_key_digest { OpenSSL::Digest::SHA256.hexdigest(SecureRandom.hex(16)) } api_key_prefix { SecureRandom.hex(4) } diff --git a/spec/models/device_spec.rb b/spec/models/device_spec.rb index 214bdd64..6403a1fd 100644 --- a/spec/models/device_spec.rb +++ b/spec/models/device_spec.rb @@ -11,15 +11,18 @@ # created_at :datetime not null # updated_at :datetime not null # language_id :bigint not null +# region_id :bigint not null # # Indexes # # index_devices_on_api_key_digest (api_key_digest) UNIQUE # index_devices_on_language_id (language_id) +# index_devices_on_region_id (region_id) # # Foreign Keys # # fk_rails_... (language_id => languages.id) +# fk_rails_... (region_id => regions.id) # require "rails_helper" @@ -35,6 +38,7 @@ describe "associations" do it { is_expected.to belong_to(:language) } + it { is_expected.to belong_to(:region) } it { is_expected.to have_many(:device_providers).dependent(:destroy) } it { is_expected.to have_many(:providers).through(:device_providers) } it { is_expected.to have_many(:device_topics).dependent(:destroy) } diff --git a/spec/services/devices/creator_spec.rb b/spec/services/devices/creator_spec.rb index dfa1c77d..6f6e8b23 100644 --- a/spec/services/devices/creator_spec.rb +++ b/spec/services/devices/creator_spec.rb @@ -3,10 +3,11 @@ RSpec.describe Devices::Creator do describe "#call" do let(:language) { create(:language) } + let(:region) { create(:region) } it "creates a device and returns the raw key" do creator = described_class.new - success, device, raw_key = creator.call(name: "Test Device", language: language) + success, device, raw_key = creator.call(name: "Test Device", language: language, region: region) expect(success).to be true expect(device).to be_persisted @@ -15,7 +16,7 @@ it "sets the api_key_digest and api_key_prefix on the device" do creator = described_class.new - _, device, raw_key = creator.call(name: "Test Device", language: language) + _, device, raw_key = creator.call(name: "Test Device", language: language, region: region) expected_digest = OpenSSL::Digest::SHA256.hexdigest(raw_key) expect(device.api_key_digest).to eq(expected_digest) @@ -24,7 +25,7 @@ it "returns failure when params are invalid" do creator = described_class.new - success, device, raw_key = creator.call(name: "", language: language) + success, device, raw_key = creator.call(name: "", language: language, region: region) expect(success).to be false expect(device).not_to be_persisted @@ -40,7 +41,7 @@ fake_generator = instance_double(Devices::ApiKeyGenerator, call: fake_result) creator = described_class.new(key_generator: fake_generator) - success, device, raw_key = creator.call(name: "Test Device", language: language) + success, device, raw_key = creator.call(name: "Test Device", language: language, region: region) expect(success).to be true expect(device.api_key_digest).to eq("fake_digest_value") From b82a89f0f937540d18508702aee4a22ba29d2c44 Mon Sep 17 00:00:00 2001 From: dmitrytrager Date: Tue, 3 Feb 2026 12:06:44 +0100 Subject: [PATCH 3/3] Rename device to beacon throughout the codebase Rename all tables, models, controllers, services, routes, specs, and factories from device/Device to beacon/Beacon to better reflect the domain terminology. Co-Authored-By: Claude Opus 4.5 --- .../{devices => beacons}/base_controller.rb | 4 +- .../api/v1/beacons/statuses_controller.rb | 17 ++++ .../api/v1/devices/statuses_controller.rb | 17 ---- ...entication.rb => beacon_authentication.rb} | 14 ++-- app/models/{device.rb => beacon.rb} | 18 ++--- ...{device_provider.rb => beacon_provider.rb} | 16 ++-- app/models/beacon_topic.rb | 26 +++++++ app/models/current.rb | 2 +- app/models/device_topic.rb | 26 ------- app/models/language.rb | 2 +- app/models/provider.rb | 4 +- app/models/region.rb | 2 +- app/models/topic.rb | 4 +- .../{devices => beacons}/api_key_generator.rb | 2 +- app/services/{devices => beacons}/creator.rb | 10 +-- .../{devices => beacons}/key_regenerator.rb | 8 +- config/routes.rb | 2 +- ...0260203100004_rename_devices_to_beacons.rb | 10 +++ db/schema.rb | 78 +++++++++---------- ...evice_providers.rb => beacon_providers.rb} | 16 ++-- .../{device_topics.rb => beacon_topics.rb} | 16 ++-- spec/factories/{devices.rb => beacons.rb} | 20 ++--- ...ovider_spec.rb => beacon_provider_spec.rb} | 16 ++-- .../models/{device_spec.rb => beacon_spec.rb} | 50 ++++++------ ...ice_topic_spec.rb => beacon_topic_spec.rb} | 16 ++-- ..._spec.rb => beacon_authentication_spec.rb} | 28 +++---- .../api_key_generator_spec.rb | 2 +- .../{devices => beacons}/creator_spec.rb | 28 +++---- spec/services/beacons/key_regenerator_spec.rb | 43 ++++++++++ spec/services/devices/key_regenerator_spec.rb | 43 ---------- spec/support/beacon_authentication_helpers.rb | 21 +++++ spec/support/device_authentication_helpers.rb | 21 ----- 32 files changed, 296 insertions(+), 286 deletions(-) rename app/controllers/api/v1/{devices => beacons}/base_controller.rb (63%) create mode 100644 app/controllers/api/v1/beacons/statuses_controller.rb delete mode 100644 app/controllers/api/v1/devices/statuses_controller.rb rename app/controllers/concerns/api/{device_authentication.rb => beacon_authentication.rb} (69%) rename app/models/{device.rb => beacon.rb} (70%) rename app/models/{device_provider.rb => beacon_provider.rb} (50%) create mode 100644 app/models/beacon_topic.rb delete mode 100644 app/models/device_topic.rb rename app/services/{devices => beacons}/api_key_generator.rb (96%) rename app/services/{devices => beacons}/creator.rb (72%) rename app/services/{devices => beacons}/key_regenerator.rb (77%) create mode 100644 db/migrate/20260203100004_rename_devices_to_beacons.rb rename spec/factories/{device_providers.rb => beacon_providers.rb} (52%) rename spec/factories/{device_topics.rb => beacon_topics.rb} (51%) rename spec/factories/{devices.rb => beacons.rb} (68%) rename spec/models/{device_provider_spec.rb => beacon_provider_spec.rb} (53%) rename spec/models/{device_spec.rb => beacon_spec.rb} (59%) rename spec/models/{device_topic_spec.rb => beacon_topic_spec.rb} (52%) rename spec/requests/api/v1/{device_authentication_spec.rb => beacon_authentication_spec.rb} (57%) rename spec/services/{devices => beacons}/api_key_generator_spec.rb (96%) rename spec/services/{devices => beacons}/creator_spec.rb (57%) create mode 100644 spec/services/beacons/key_regenerator_spec.rb delete mode 100644 spec/services/devices/key_regenerator_spec.rb create mode 100644 spec/support/beacon_authentication_helpers.rb delete mode 100644 spec/support/device_authentication_helpers.rb diff --git a/app/controllers/api/v1/devices/base_controller.rb b/app/controllers/api/v1/beacons/base_controller.rb similarity index 63% rename from app/controllers/api/v1/devices/base_controller.rb rename to app/controllers/api/v1/beacons/base_controller.rb index 3467b52c..1c0380a2 100644 --- a/app/controllers/api/v1/devices/base_controller.rb +++ b/app/controllers/api/v1/beacons/base_controller.rb @@ -1,8 +1,8 @@ module Api module V1 - module Devices + module Beacons class BaseController < Api::V1::BaseController - include Api::DeviceAuthentication + include Api::BeaconAuthentication 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/api/v1/devices/statuses_controller.rb b/app/controllers/api/v1/devices/statuses_controller.rb deleted file mode 100644 index 395ab6ff..00000000 --- a/app/controllers/api/v1/devices/statuses_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -module Api - module V1 - module Devices - class StatusesController < Devices::BaseController - def show - render json: { - status: "ok", - device: { - id: Current.device.id, - name: Current.device.name, - }, - } - end - end - end - end -end diff --git a/app/controllers/concerns/api/device_authentication.rb b/app/controllers/concerns/api/beacon_authentication.rb similarity index 69% rename from app/controllers/concerns/api/device_authentication.rb rename to app/controllers/concerns/api/beacon_authentication.rb index a5f0ce98..d9e32340 100644 --- a/app/controllers/concerns/api/device_authentication.rb +++ b/app/controllers/concerns/api/beacon_authentication.rb @@ -1,24 +1,24 @@ module Api - module DeviceAuthentication + module BeaconAuthentication extend ActiveSupport::Concern included do - before_action :authenticate_device! + before_action :authenticate_beacon! end private - def authenticate_device! + def authenticate_beacon! token = extract_bearer_token return render_unauthorized("Missing authorization header") if token.blank? digest = OpenSSL::Digest::SHA256.hexdigest(token) - device = Device.find_by(api_key_digest: digest) + beacon = Beacon.find_by(api_key_digest: digest) - return render_unauthorized("Invalid API key") if device.nil? - return render_unauthorized("API key has been revoked") if device.revoked? + return render_unauthorized("Invalid API key") if beacon.nil? + return render_unauthorized("API key has been revoked") if beacon.revoked? - Current.device = device + Current.beacon = beacon end def extract_bearer_token diff --git a/app/models/device.rb b/app/models/beacon.rb similarity index 70% rename from app/models/device.rb rename to app/models/beacon.rb index 42a4eb58..d613ae38 100644 --- a/app/models/device.rb +++ b/app/models/beacon.rb @@ -1,6 +1,6 @@ # == Schema Information # -# Table name: devices +# Table name: beacons # Database name: primary # # id :bigint not null, primary key @@ -15,24 +15,24 @@ # # Indexes # -# index_devices_on_api_key_digest (api_key_digest) UNIQUE -# index_devices_on_language_id (language_id) -# index_devices_on_region_id (region_id) +# 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 Device < ApplicationRecord +class Beacon < ApplicationRecord belongs_to :language belongs_to :region - has_many :device_providers, dependent: :destroy - has_many :providers, through: :device_providers + has_many :beacon_providers, dependent: :destroy + has_many :providers, through: :beacon_providers - has_many :device_topics, dependent: :destroy - has_many :topics, through: :device_topics + has_many :beacon_topics, dependent: :destroy + has_many :topics, through: :beacon_topics validates :name, presence: true validates :api_key_digest, presence: true, uniqueness: true diff --git a/app/models/device_provider.rb b/app/models/beacon_provider.rb similarity index 50% rename from app/models/device_provider.rb rename to app/models/beacon_provider.rb index e2940b87..4dd8ec96 100644 --- a/app/models/device_provider.rb +++ b/app/models/beacon_provider.rb @@ -1,26 +1,26 @@ # == Schema Information # -# Table name: device_providers +# Table name: beacon_providers # Database name: primary # # id :bigint not null, primary key # created_at :datetime not null # updated_at :datetime not null -# device_id :bigint not null +# beacon_id :bigint not null # provider_id :bigint not null # # Indexes # -# index_device_providers_on_device_id (device_id) -# index_device_providers_on_device_id_and_provider_id (device_id,provider_id) UNIQUE -# index_device_providers_on_provider_id (provider_id) +# 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_... (device_id => devices.id) +# fk_rails_... (beacon_id => beacons.id) # fk_rails_... (provider_id => providers.id) # -class DeviceProvider < ApplicationRecord - belongs_to :device +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/current.rb b/app/models/current.rb index 3e5b9c1e..5725e6e2 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,5 +1,5 @@ class Current < ActiveSupport::CurrentAttributes attribute :session - attribute :device + attribute :beacon delegate :user, to: :session, allow_nil: true end diff --git a/app/models/device_topic.rb b/app/models/device_topic.rb deleted file mode 100644 index f262853c..00000000 --- a/app/models/device_topic.rb +++ /dev/null @@ -1,26 +0,0 @@ -# == Schema Information -# -# Table name: device_topics -# Database name: primary -# -# id :bigint not null, primary key -# created_at :datetime not null -# updated_at :datetime not null -# device_id :bigint not null -# topic_id :bigint not null -# -# Indexes -# -# index_device_topics_on_device_id (device_id) -# index_device_topics_on_device_id_and_topic_id (device_id,topic_id) UNIQUE -# index_device_topics_on_topic_id (topic_id) -# -# Foreign Keys -# -# fk_rails_... (device_id => devices.id) -# fk_rails_... (topic_id => topics.id) -# -class DeviceTopic < ApplicationRecord - belongs_to :device - belongs_to :topic -end diff --git a/app/models/language.rb b/app/models/language.rb index c0b7ce6b..4c1f78fb 100644 --- a/app/models/language.rb +++ b/app/models/language.rb @@ -11,7 +11,7 @@ class Language < ApplicationRecord has_many :topics, dependent: :destroy has_many :providers, through: :topics - has_many :devices, dependent: :destroy + 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 eb386ea7..2a448d8e 100644 --- a/app/models/provider.rb +++ b/app/models/provider.rb @@ -21,8 +21,8 @@ class Provider < ApplicationRecord has_many :contributors has_many :users, through: :contributors has_many :topics - has_many :device_providers, dependent: :destroy - has_many :devices, through: :device_providers + 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 84c88cb4..be4b4ee8 100644 --- a/app/models/region.rb +++ b/app/models/region.rb @@ -11,7 +11,7 @@ class Region < ApplicationRecord has_many :branches, dependent: :destroy has_many :providers, through: :branches - has_many :devices, dependent: :destroy + has_many :beacons, dependent: :destroy validates :name, presence: true end diff --git a/app/models/topic.rb b/app/models/topic.rb index 56df24e2..f78ebb2a 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -36,8 +36,8 @@ class Topic < ApplicationRecord belongs_to :language belongs_to :provider - has_many :device_topics, dependent: :destroy - has_many :devices, through: :device_topics + 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/services/devices/api_key_generator.rb b/app/services/beacons/api_key_generator.rb similarity index 96% rename from app/services/devices/api_key_generator.rb rename to app/services/beacons/api_key_generator.rb index ec1f43e9..dbd015cc 100644 --- a/app/services/devices/api_key_generator.rb +++ b/app/services/beacons/api_key_generator.rb @@ -1,4 +1,4 @@ -module Devices +module Beacons class ApiKeyGenerator PREFIX = "sk_live_" diff --git a/app/services/devices/creator.rb b/app/services/beacons/creator.rb similarity index 72% rename from app/services/devices/creator.rb rename to app/services/beacons/creator.rb index 4842b42a..cbb18398 100644 --- a/app/services/devices/creator.rb +++ b/app/services/beacons/creator.rb @@ -1,4 +1,4 @@ -module Devices +module Beacons class Creator attr_reader :key_generator @@ -9,16 +9,16 @@ def initialize(key_generator: ApiKeyGenerator.new) def call(params) key_result = key_generator.call - device = Device.new( + beacon = Beacon.new( **params, api_key_digest: key_result.digest, api_key_prefix: key_result.prefix, ) - if device.save - [ true, device, key_result.raw_key ] + if beacon.save + [ true, beacon, key_result.raw_key ] else - [ false, device, nil ] + [ false, beacon, nil ] end end end diff --git a/app/services/devices/key_regenerator.rb b/app/services/beacons/key_regenerator.rb similarity index 77% rename from app/services/devices/key_regenerator.rb rename to app/services/beacons/key_regenerator.rb index 4386f202..ac4e4923 100644 --- a/app/services/devices/key_regenerator.rb +++ b/app/services/beacons/key_regenerator.rb @@ -1,4 +1,4 @@ -module Devices +module Beacons class KeyRegenerator attr_reader :key_generator @@ -6,16 +6,16 @@ def initialize(key_generator: ApiKeyGenerator.new) @key_generator = key_generator end - def call(device) + def call(beacon) key_result = key_generator.call - device.update!( + beacon.update!( api_key_digest: key_result.digest, api_key_prefix: key_result.prefix, revoked_at: nil, ) - [ device, key_result.raw_key ] + [ beacon, key_result.raw_key ] end end end diff --git a/config/routes.rb b/config/routes.rb index ef8a748c..bfa18754 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -40,7 +40,7 @@ namespace :v1 do resources :tags, only: %i[index show] - namespace :devices do + namespace :beacons do resource :status, only: :show 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/schema.rb b/db/schema.rb index 5475fd53..46aaf864 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_03_100003) 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" @@ -42,45 +42,27 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end - create_table "branches", force: :cascade do |t| - t.datetime "created_at", null: false - t.bigint "provider_id" - t.bigint "region_id" - 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.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 "device_providers", force: :cascade do |t| + create_table "beacon_providers", force: :cascade do |t| + t.bigint "beacon_id", null: false t.datetime "created_at", null: false - t.bigint "device_id", null: false t.bigint "provider_id", null: false t.datetime "updated_at", null: false - t.index ["device_id", "provider_id"], name: "index_device_providers_on_device_id_and_provider_id", unique: true - t.index ["device_id"], name: "index_device_providers_on_device_id" - t.index ["provider_id"], name: "index_device_providers_on_provider_id" + 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 "device_topics", force: :cascade do |t| + create_table "beacon_topics", force: :cascade do |t| + t.bigint "beacon_id", null: false t.datetime "created_at", null: false - t.bigint "device_id", null: false t.bigint "topic_id", null: false t.datetime "updated_at", null: false - t.index ["device_id", "topic_id"], name: "index_device_topics_on_device_id_and_topic_id", unique: true - t.index ["device_id"], name: "index_device_topics_on_device_id" - t.index ["topic_id"], name: "index_device_topics_on_topic_id" + 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 "devices", force: :cascade do |t| + 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 @@ -89,9 +71,27 @@ t.bigint "region_id", null: false t.datetime "revoked_at" t.datetime "updated_at", null: false - t.index ["api_key_digest"], name: "index_devices_on_api_key_digest", unique: true - t.index ["language_id"], name: "index_devices_on_language_id" - t.index ["region_id"], name: "index_devices_on_region_id" + 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 "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.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| @@ -221,12 +221,12 @@ 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 "device_providers", "devices" - add_foreign_key "device_providers", "providers" - add_foreign_key "device_topics", "devices" - add_foreign_key "device_topics", "topics" - add_foreign_key "devices", "languages" - add_foreign_key "devices", "regions" + 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/device_providers.rb b/spec/factories/beacon_providers.rb similarity index 52% rename from spec/factories/device_providers.rb rename to spec/factories/beacon_providers.rb index 777f4881..43567fe1 100644 --- a/spec/factories/device_providers.rb +++ b/spec/factories/beacon_providers.rb @@ -1,28 +1,28 @@ # == Schema Information # -# Table name: device_providers +# Table name: beacon_providers # Database name: primary # # id :bigint not null, primary key # created_at :datetime not null # updated_at :datetime not null -# device_id :bigint not null +# beacon_id :bigint not null # provider_id :bigint not null # # Indexes # -# index_device_providers_on_device_id (device_id) -# index_device_providers_on_device_id_and_provider_id (device_id,provider_id) UNIQUE -# index_device_providers_on_provider_id (provider_id) +# 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_... (device_id => devices.id) +# fk_rails_... (beacon_id => beacons.id) # fk_rails_... (provider_id => providers.id) # FactoryBot.define do - factory :device_provider do - association :device + factory :beacon_provider do + association :beacon association :provider end end diff --git a/spec/factories/device_topics.rb b/spec/factories/beacon_topics.rb similarity index 51% rename from spec/factories/device_topics.rb rename to spec/factories/beacon_topics.rb index d1017e2b..8c16079c 100644 --- a/spec/factories/device_topics.rb +++ b/spec/factories/beacon_topics.rb @@ -1,28 +1,28 @@ # == Schema Information # -# Table name: device_topics +# Table name: beacon_topics # Database name: primary # # id :bigint not null, primary key # created_at :datetime not null # updated_at :datetime not null -# device_id :bigint not null +# beacon_id :bigint not null # topic_id :bigint not null # # Indexes # -# index_device_topics_on_device_id (device_id) -# index_device_topics_on_device_id_and_topic_id (device_id,topic_id) UNIQUE -# index_device_topics_on_topic_id (topic_id) +# 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_... (device_id => devices.id) +# fk_rails_... (beacon_id => beacons.id) # fk_rails_... (topic_id => topics.id) # FactoryBot.define do - factory :device_topic do - association :device + factory :beacon_topic do + association :beacon association :topic end end diff --git a/spec/factories/devices.rb b/spec/factories/beacons.rb similarity index 68% rename from spec/factories/devices.rb rename to spec/factories/beacons.rb index 6ec95b2a..41753b49 100644 --- a/spec/factories/devices.rb +++ b/spec/factories/beacons.rb @@ -1,6 +1,6 @@ # == Schema Information # -# Table name: devices +# Table name: beacons # Database name: primary # # id :bigint not null, primary key @@ -15,9 +15,9 @@ # # Indexes # -# index_devices_on_api_key_digest (api_key_digest) UNIQUE -# index_devices_on_language_id (language_id) -# index_devices_on_region_id (region_id) +# 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 # @@ -25,8 +25,8 @@ # fk_rails_... (region_id => regions.id) # FactoryBot.define do - factory :device do - sequence(:name) { |n| "Device #{n}" } + factory :beacon do + sequence(:name) { |n| "Beacon #{n}" } association :language association :region api_key_digest { OpenSSL::Digest::SHA256.hexdigest(SecureRandom.hex(16)) } @@ -41,8 +41,8 @@ provider_count { 2 } end - after(:create) do |device, evaluator| - create_list(:device_provider, evaluator.provider_count, device: device) + after(:create) do |beacon, evaluator| + create_list(:beacon_provider, evaluator.provider_count, beacon: beacon) end end @@ -51,8 +51,8 @@ topic_count { 2 } end - after(:create) do |device, evaluator| - create_list(:device_topic, evaluator.topic_count, device: device) + after(:create) do |beacon, evaluator| + create_list(:beacon_topic, evaluator.topic_count, beacon: beacon) end end end diff --git a/spec/models/device_provider_spec.rb b/spec/models/beacon_provider_spec.rb similarity index 53% rename from spec/models/device_provider_spec.rb rename to spec/models/beacon_provider_spec.rb index 27215c4a..0c282236 100644 --- a/spec/models/device_provider_spec.rb +++ b/spec/models/beacon_provider_spec.rb @@ -1,30 +1,30 @@ # == Schema Information # -# Table name: device_providers +# Table name: beacon_providers # Database name: primary # # id :bigint not null, primary key # created_at :datetime not null # updated_at :datetime not null -# device_id :bigint not null +# beacon_id :bigint not null # provider_id :bigint not null # # Indexes # -# index_device_providers_on_device_id (device_id) -# index_device_providers_on_device_id_and_provider_id (device_id,provider_id) UNIQUE -# index_device_providers_on_provider_id (provider_id) +# 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_... (device_id => devices.id) +# fk_rails_... (beacon_id => beacons.id) # fk_rails_... (provider_id => providers.id) # require "rails_helper" -RSpec.describe DeviceProvider, type: :model do +RSpec.describe BeaconProvider, type: :model do describe "associations" do - it { is_expected.to belong_to(:device) } + it { is_expected.to belong_to(:beacon) } it { is_expected.to belong_to(:provider) } end end diff --git a/spec/models/device_spec.rb b/spec/models/beacon_spec.rb similarity index 59% rename from spec/models/device_spec.rb rename to spec/models/beacon_spec.rb index 6403a1fd..f397a629 100644 --- a/spec/models/device_spec.rb +++ b/spec/models/beacon_spec.rb @@ -1,6 +1,6 @@ # == Schema Information # -# Table name: devices +# Table name: beacons # Database name: primary # # id :bigint not null, primary key @@ -15,9 +15,9 @@ # # Indexes # -# index_devices_on_api_key_digest (api_key_digest) UNIQUE -# index_devices_on_language_id (language_id) -# index_devices_on_region_id (region_id) +# 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 # @@ -26,8 +26,8 @@ # require "rails_helper" -RSpec.describe Device, type: :model do - subject { create(:device) } +RSpec.describe Beacon, type: :model do + subject { create(:beacon) } describe "validations" do it { is_expected.to validate_presence_of(:name) } @@ -39,25 +39,25 @@ describe "associations" do it { is_expected.to belong_to(:language) } it { is_expected.to belong_to(:region) } - it { is_expected.to have_many(:device_providers).dependent(:destroy) } - it { is_expected.to have_many(:providers).through(:device_providers) } - it { is_expected.to have_many(:device_topics).dependent(:destroy) } - it { is_expected.to have_many(:topics).through(:device_topics) } + 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_device) { create(:device) } - let!(:revoked_device) { create(:device, :revoked) } + let!(:active_beacon) { create(:beacon) } + let!(:revoked_beacon) { create(:beacon, :revoked) } describe ".active" do - it "returns only active devices" do - expect(described_class.active).to contain_exactly(active_device) + it "returns only active beacons" do + expect(described_class.active).to contain_exactly(active_beacon) end end describe ".revoked" do - it "returns only revoked devices" do - expect(described_class.revoked).to contain_exactly(revoked_device) + it "returns only revoked beacons" do + expect(described_class.revoked).to contain_exactly(revoked_beacon) end end end @@ -66,25 +66,25 @@ include ActiveSupport::Testing::TimeHelpers it "sets revoked_at to the current time" do - device = create(:device) + beacon = create(:beacon) now = Time.current travel_to(now) do - device.revoke! - expect(device.revoked_at).to be_within(1.second).of(now) + beacon.revoke! + expect(beacon.revoked_at).to be_within(1.second).of(now) end end end describe "#revoked?" do - it "returns false for active devices" do - device = create(:device) - expect(device).not_to be_revoked + it "returns false for active beacons" do + beacon = create(:beacon) + expect(beacon).not_to be_revoked end - it "returns true for revoked devices" do - device = create(:device, :revoked) - expect(device).to be_revoked + it "returns true for revoked beacons" do + beacon = create(:beacon, :revoked) + expect(beacon).to be_revoked end end end diff --git a/spec/models/device_topic_spec.rb b/spec/models/beacon_topic_spec.rb similarity index 52% rename from spec/models/device_topic_spec.rb rename to spec/models/beacon_topic_spec.rb index 3d2ef0c7..1e8a90b1 100644 --- a/spec/models/device_topic_spec.rb +++ b/spec/models/beacon_topic_spec.rb @@ -1,30 +1,30 @@ # == Schema Information # -# Table name: device_topics +# Table name: beacon_topics # Database name: primary # # id :bigint not null, primary key # created_at :datetime not null # updated_at :datetime not null -# device_id :bigint not null +# beacon_id :bigint not null # topic_id :bigint not null # # Indexes # -# index_device_topics_on_device_id (device_id) -# index_device_topics_on_device_id_and_topic_id (device_id,topic_id) UNIQUE -# index_device_topics_on_topic_id (topic_id) +# 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_... (device_id => devices.id) +# fk_rails_... (beacon_id => beacons.id) # fk_rails_... (topic_id => topics.id) # require "rails_helper" -RSpec.describe DeviceTopic, type: :model do +RSpec.describe BeaconTopic, type: :model do describe "associations" do - it { is_expected.to belong_to(:device) } + it { is_expected.to belong_to(:beacon) } it { is_expected.to belong_to(:topic) } end end diff --git a/spec/requests/api/v1/device_authentication_spec.rb b/spec/requests/api/v1/beacon_authentication_spec.rb similarity index 57% rename from spec/requests/api/v1/device_authentication_spec.rb rename to spec/requests/api/v1/beacon_authentication_spec.rb index f3008451..f74e24a3 100644 --- a/spec/requests/api/v1/device_authentication_spec.rb +++ b/spec/requests/api/v1/beacon_authentication_spec.rb @@ -1,45 +1,45 @@ require "rails_helper" -RSpec.describe "Device Authentication", type: :request do - describe "GET /api/v1/devices/status" do - it "returns device info with a valid API key" do - device, raw_key = create_device_with_key(name: "Test Device") +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/devices/status", headers: device_auth_headers(raw_key) + 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["device"]["id"]).to eq(device.id) - expect(body["device"]["name"]).to eq("Test Device") + 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/devices/status" + 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/devices/status", headers: device_auth_headers("sk_live_invalid_key_000000000") + 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 device is revoked" do - device, raw_key = create_device_with_key - device.revoke! + it "returns unauthorized when the beacon is revoked" do + beacon, raw_key = create_beacon_with_key + beacon.revoke! - get "/api/v1/devices/status", headers: device_auth_headers(raw_key) + 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/devices/status", headers: { "Authorization" => "Token sk_live_something" } + 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") diff --git a/spec/services/devices/api_key_generator_spec.rb b/spec/services/beacons/api_key_generator_spec.rb similarity index 96% rename from spec/services/devices/api_key_generator_spec.rb rename to spec/services/beacons/api_key_generator_spec.rb index e6473f46..761041d4 100644 --- a/spec/services/devices/api_key_generator_spec.rb +++ b/spec/services/beacons/api_key_generator_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe Devices::ApiKeyGenerator do +RSpec.describe Beacons::ApiKeyGenerator do subject(:generator) { described_class.new } describe "#call" do diff --git a/spec/services/devices/creator_spec.rb b/spec/services/beacons/creator_spec.rb similarity index 57% rename from spec/services/devices/creator_spec.rb rename to spec/services/beacons/creator_spec.rb index 6f6e8b23..cc409388 100644 --- a/spec/services/devices/creator_spec.rb +++ b/spec/services/beacons/creator_spec.rb @@ -1,50 +1,50 @@ require "rails_helper" -RSpec.describe Devices::Creator do +RSpec.describe Beacons::Creator do describe "#call" do let(:language) { create(:language) } let(:region) { create(:region) } - it "creates a device and returns the raw key" do + it "creates a beacon and returns the raw key" do creator = described_class.new - success, device, raw_key = creator.call(name: "Test Device", language: language, region: region) + success, beacon, raw_key = creator.call(name: "Test Beacon", language: language, region: region) expect(success).to be true - expect(device).to be_persisted + 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 device" do + it "sets the api_key_digest and api_key_prefix on the beacon" do creator = described_class.new - _, device, raw_key = creator.call(name: "Test Device", language: language, region: region) + _, beacon, raw_key = creator.call(name: "Test Beacon", language: language, region: region) expected_digest = OpenSSL::Digest::SHA256.hexdigest(raw_key) - expect(device.api_key_digest).to eq(expected_digest) - expect(device.api_key_prefix).to eq(raw_key.delete_prefix("sk_live_")[0, 8]) + 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, device, raw_key = creator.call(name: "", language: language, region: region) + success, beacon, raw_key = creator.call(name: "", language: language, region: region) expect(success).to be false - expect(device).not_to be_persisted + 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 = Devices::ApiKeyGenerator::Result.new( + fake_result = Beacons::ApiKeyGenerator::Result.new( raw_key: "sk_live_custom1234567890abcdef", digest: "fake_digest_value", prefix: "custom12", ) - fake_generator = instance_double(Devices::ApiKeyGenerator, call: fake_result) + fake_generator = instance_double(Beacons::ApiKeyGenerator, call: fake_result) creator = described_class.new(key_generator: fake_generator) - success, device, raw_key = creator.call(name: "Test Device", language: language, region: region) + success, beacon, raw_key = creator.call(name: "Test Beacon", language: language, region: region) expect(success).to be true - expect(device.api_key_digest).to eq("fake_digest_value") + expect(beacon.api_key_digest).to eq("fake_digest_value") expect(raw_key).to eq("sk_live_custom1234567890abcdef") 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/services/devices/key_regenerator_spec.rb b/spec/services/devices/key_regenerator_spec.rb deleted file mode 100644 index 4496ac78..00000000 --- a/spec/services/devices/key_regenerator_spec.rb +++ /dev/null @@ -1,43 +0,0 @@ -require "rails_helper" - -RSpec.describe Devices::KeyRegenerator do - describe "#call" do - it "generates a new key for the device" do - device = create(:device) - old_digest = device.api_key_digest - - regenerator = described_class.new - _, raw_key = regenerator.call(device) - - expect(device.reload.api_key_digest).not_to eq(old_digest) - expect(raw_key).to start_with("sk_live_") - expect(device.api_key_digest).to eq(OpenSSL::Digest::SHA256.hexdigest(raw_key)) - end - - it "clears revoked_at on a revoked device" do - device = create(:device, :revoked) - expect(device.revoked_at).to be_present - - regenerator = described_class.new - regenerator.call(device) - - expect(device.reload.revoked_at).to be_nil - end - - it "accepts a custom key generator via dependency injection" do - device = create(:device) - fake_result = Devices::ApiKeyGenerator::Result.new( - raw_key: "sk_live_injected_key_1234abcd", - digest: "injected_digest_value", - prefix: "injected", - ) - fake_generator = instance_double(Devices::ApiKeyGenerator, call: fake_result) - - regenerator = described_class.new(key_generator: fake_generator) - returned_device, raw_key = regenerator.call(device) - - expect(returned_device.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/spec/support/device_authentication_helpers.rb b/spec/support/device_authentication_helpers.rb deleted file mode 100644 index 1190d7eb..00000000 --- a/spec/support/device_authentication_helpers.rb +++ /dev/null @@ -1,21 +0,0 @@ -module DeviceAuthenticationHelpers - def device_auth_headers(raw_key) - { "Authorization" => "Bearer #{raw_key}" } - end - - def create_device_with_key(attributes = {}) - generator = Devices::ApiKeyGenerator.new - key_result = generator.call - - device = create(:device, **attributes.merge( - api_key_digest: key_result.digest, - api_key_prefix: key_result.prefix, - )) - - [ device, key_result.raw_key ] - end -end - -RSpec.configure do |config| - config.include DeviceAuthenticationHelpers, type: :request -end