diff --git a/Gemfile b/Gemfile index f5bb620..0ba5130 100644 --- a/Gemfile +++ b/Gemfile @@ -47,3 +47,6 @@ gem 'rspec-core' # Common code needed by the other RSpec gems. Not intended for direct use [https://github.com/rspec/rspec-support] gem 'rspec-support' + +# Code coverage analysis tool for Ruby [https://github.com/simplecov-ruby/simplecov] +gem 'simplecov', '~> 0.22', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 4e1697f..7139644 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - devise-api (0.1.3) + devise-api (0.2.0) devise (>= 4.7.2) dry-configurable (~> 1.0, >= 1.0.1) dry-initializer (>= 3.1.1) @@ -98,6 +98,7 @@ GEM responders warden (~> 1.2.3) diff-lcs (1.5.0) + docile (1.4.1) dry-configurable (1.0.1) dry-core (~> 1.0, < 2) zeitwerk (~> 2.6) @@ -236,6 +237,12 @@ GEM rubocop-ast (1.24.1) parser (>= 3.1.1.0) ruby-progressbar (1.11.0) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) sprockets (4.2.0) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) @@ -260,6 +267,7 @@ PLATFORMS arm64-darwin-21 arm64-darwin-22 arm64-darwin-23 + arm64-darwin-25 DEPENDENCIES awesome_print @@ -275,6 +283,7 @@ DEPENDENCIES rspec-rails (~> 6.0, >= 6.0.1) rspec-support rubocop (~> 1.21) + simplecov (~> 0.22) sprockets-rails sqlite3 (~> 1.4) diff --git a/Rakefile b/Rakefile index 0803ad2..2520194 100644 --- a/Rakefile +++ b/Rakefile @@ -5,6 +5,12 @@ require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:rspec) +# Full-suite runs enforce the SimpleCov minimum; single-file `rspec` runs do not. +task :enforce_coverage do + ENV['ENFORCE_COVERAGE'] = '1' +end +task rspec: :enforce_coverage + require 'rubocop/rake_task' RuboCop::RakeTask.new diff --git a/app/services/devise/api/tokens_service/create.rb b/app/services/devise/api/tokens_service/create.rb index 2a58aec..bed7a75 100644 --- a/app/services/devise/api/tokens_service/create.rb +++ b/app/services/devise/api/tokens_service/create.rb @@ -17,11 +17,6 @@ def call private - def authenticate_service - Devise::Api::ResourceOwnerService::Authenticate.new(params: params, - resource_class: resource_class).call - end - def create_devise_api_token devise_api_token = resource_owner.access_tokens.new(params) diff --git a/docs/analysis/known-issues.md b/docs/analysis/known-issues.md index f201adf..9ae29ef 100644 --- a/docs/analysis/known-issues.md +++ b/docs/analysis/known-issues.md @@ -18,8 +18,8 @@ Defined twice: memoized in `TokensController` (`app/controllers/devise/api/token ## Dead / vestigial code -### KI-5 · Dead method in `TokensService::Create` -`#authenticate_service` (`app/services/devise/api/tokens_service/create.rb:20-23`) is never called and references `params` / `resource_class`, which don't exist on this service — it would `NameError` if invoked. Copy-paste leftover; delete. +### KI-5 · ~~Dead method in `TokensService::Create`~~ (resolved) +`#authenticate_service` was never called and referenced `params` / `resource_class`, which didn't exist on this service — it would have `NameError`d if invoked. Copy-paste leftover; deleted as part of the coverage push. ### KI-6 · RBS stub `sig/devise/api.rbs` declares only the `VERSION` constant. Either flesh out signatures or drop the `sig/` directory to avoid signaling type support that doesn't exist. @@ -43,14 +43,14 @@ Only records `0.0.0` while the gem is at `0.2.0` with substantive releases in be ## Test-coverage gaps (feeds the "add more tests" milestone) -### KI-12 · Service specs are placeholders -All six `spec/services/**` files only assert inheritance from `BaseService`. Real branch coverage (monad contracts per [services.md](../services.md)) is missing at the unit level. +### KI-12 · ~~Service specs are placeholders~~ (resolved) +All six `spec/services/**` files now assert the monad contracts (`Success`/`Failure` per branch, per [services.md](../services.md)), including the failure paths unreachable through the HTTP API (`:invalid_resource_owner`, `:devise_api_token_create_error`, `:devise_api_token_revoke_error`, sign-up transaction rollback). -### KI-13 · No generator specs -`rails g devise_api:install` (migration template rendering incl. UUID primary-key handling, locale copy) is untested despite `spec_helper` requiring the generator test harness. +### KI-13 · ~~No generator specs~~ (resolved) +`rails g devise_api:install` is covered by `spec/devise/api/generators/install_generator_spec.rb` (migration template rendering with the current Active Record version, locale copy, migration numbering). -### KI-14 · Non-default configuration is untested -No specs exercise: `authorization.location = :header`/`:params` exclusively, custom `authorization.key`/`scheme`/`params_key`, `sign_up.enabled = false`, `refresh_token.enabled = false`, `expires_in_infinite` procs, custom generators, `sign_up.extra_fields`, `base_token_model`/`base_controller` overrides, or any before/after callback invocation. +### KI-14 · Non-default configuration is untested (mostly resolved) +`spec/requests/configuration_overrides_spec.rb` covers `authorization.location = :header`/`:params` exclusively, `sign_up.enabled = false`, `refresh_token.enabled = false` and `sign_up.extra_fields` end-to-end; `spec/devise/api/token_spec.rb` covers `expires_in_infinite` procs and custom generators; `spec/devise/api/configuration_spec.rb` covers overrides on fresh instances; callback invocation was already asserted in `spec/requests/tokens_spec.rb`. Still untested: custom `authorization.key`/`scheme`/`params_key` and `base_token_model`/`base_controller` overrides. ## Cross-references into security review diff --git a/docs/development.md b/docs/development.md index 099ca07..0135889 100644 --- a/docs/development.md +++ b/docs/development.md @@ -41,7 +41,7 @@ Two workflows on every push, matrix over Ruby 2.7.7 / 3.0.5 / 3.1.3 / 3.2.0: - `test.yml` — `bundle install` + `bundle exec rake rspec` - `rubocop.yml` — `bundle exec rubocop --config .rubocop.yml --parallel` -No coverage reporting, no scheduled builds, no release automation. +SimpleCov coverage runs with the suite (95% line minimum enforced on full-suite runs — see [testing.md](testing.md)). No scheduled builds, no release automation. ## Pointers for common change types diff --git a/docs/services.md b/docs/services.md index 7941a0f..880d518 100644 --- a/docs/services.md +++ b/docs/services.md @@ -52,7 +52,6 @@ graph LR - **Inputs:** `resource_owner` (untyped), `previous_refresh_token: String | Nil = nil` - **Logic:** guards `resource_owner.respond_to?(:access_tokens)` → builds row with generated unique access/refresh tokens, `expires_in` snapshot from config, `previous_refresh_token` passthrough. - **Success:** the token. **Failures:** `:invalid_resource_owner`, `:devise_api_token_create_error`. -- ⚠ Contains a dead private method `authenticate_service` referencing undefined `params`/`resource_class` (never called; tracked in known-issues). ### `Refresh` - **Inputs:** `devise_api_token` (typed `Types.Instance()` — resolved at class load), `resource_owner` (defaults to the token's owner) diff --git a/docs/testing.md b/docs/testing.md index c71a78f..37575fd 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,9 +12,18 @@ bundle exec rspec --only-failures # uses .rspec_status CI (`.github/workflows/test.yml`, `rubocop.yml`) runs on push across Ruby 2.7 / 3.0 / 3.1 / 3.2. +## Coverage + +SimpleCov runs automatically with every spec run (started at the top of `spec/spec_helper.rb`, before the gem +is required); the HTML report lands in `coverage/index.html` and the summary in `coverage/.last_run.json`. +A **95% line-coverage minimum** is enforced whenever `CI` or `ENFORCE_COVERAGE` is set — `bundle exec rake` +sets `ENFORCE_COVERAGE` via the `enforce_coverage` prerequisite task, so full-suite runs (local and CI) fail +below the bar while single-file `bundle exec rspec` runs stay unaffected. Branch coverage is reported but not +enforced. `lib/devise/api/version.rb` is filtered because the gemspec loads it before SimpleCov can start. + ## How the suite is wired -- `spec/spec_helper.rb` sets `RAILS_ENV=test`, requires the gem, then boots the **dummy Rails app** at `spec/dummy` (`require 'dummy/config/environment'`) — a real Rails 7 app with sqlite3 whose `User` model enables `database_authenticatable, registerable, recoverable, rememberable, validatable, confirmable, lockable, trackable, :api` (schema: `spec/dummy/db/schema.rb`). +- `spec/spec_helper.rb` sets `RAILS_ENV=test`, starts SimpleCov, requires the gem, then boots the **dummy Rails app** at `spec/dummy` (`require 'dummy/config/environment'`) — a real Rails 7 app with sqlite3 whose `User` model enables `database_authenticatable, registerable, recoverable, rememberable, validatable, confirmable, lockable, trackable, :api` (schema: `spec/dummy/db/schema.rb`). A second bare model, `AdminUser` (`database_authenticatable, registerable, validatable, :api` only), exists to exercise the "optional Devise module not enabled" branches (non-trackable sign-in, error/token responses without `lockable`/`confirmable` info); it has its own `devise_for :admin_users` routes. - `DatabaseCleaner` wraps every example; spec types are inferred from file location; monkey-patching is disabled (`RSpec.describe` only). - `spec/supports/` is auto-required: FactoryBot setup, ActiveRecord config, and two request-spec helpers: - `authentication_headers_for(owner, token = nil, token_type = :access_token)` → `{ Authorization: "Bearer …" }` (creates a token via FactoryBot when none given; pass `:refresh_token` to authenticate refresh calls) @@ -23,6 +32,7 @@ CI (`.github/workflows/test.yml`, `rubocop.yml`) runs on push across Ruby 2.7 / ## Factories (`spec/factories/`) - `:user` — Faker email/password. +- `:admin_user` — Faker email/password (bare model without optional Devise modules). - `:devise_api_token` — random hex tokens, `expires_in: 1.hour`, associated `:user`. Traits: `:access_token_expired` (backdates `created_at` 2h), `:refresh_token_expired` (2 months), `:revoked`. Note the traits work by **backdating `created_at`** because all expiry math derives from it — keep that in mind when adding time-sensitive specs (or use `travel_to`). @@ -32,13 +42,16 @@ Note the traits work by **backdating `created_at`** because all expiry math deri | Area | Spec | State | |---|---|---| | All 5 endpoints × valid/invalid/expired/revoked × header/param | `spec/requests/tokens_spec.rb` (~700 lines) | ✅ primary coverage | +| Endpoints for a bare model (no trackable/lockable/confirmable) | `spec/requests/admin_user_tokens_spec.rb` | ✅ | | `authenticate_devise_api_token!` on a host controller | `spec/requests/authentication_spec.rb` (via dummy `HomeController`) | ✅ | -| Default + customized routes (`controllers:`, `path:` overrides) | `spec/routing/*.rb` | ✅ | -| Config defaults | `spec/devise/api/configuration_spec.rb` | ✅ defaults only | -| Response classes | `spec/devise/api/responses/*_spec.rb` | partial | -| **Service objects** | `spec/services/**` | ⚠ **placeholders** — each spec only asserts inheritance from `BaseService` | -| Generator | required in spec_helper, no assertions | ⚠ gap | -| Non-default config (custom generators, `:header`-only location, disabled sign_up/refresh, `expires_in_infinite`, extra_fields) | — | ⚠ gap | +| Non-default config (disabled sign_up/refresh, extra_fields, `:header`/`:params`-only location, revoke failure) | `spec/requests/configuration_overrides_spec.rb` | ✅ | +| Default + customized routes (`controllers:`, `path:`, `path_names:` overrides) | `spec/routing/*.rb` | ✅ | +| Config defaults + overrides on fresh instances | `spec/devise/api/configuration_spec.rb` | ✅ | +| Response classes (incl. locked/unconfirmed/bare-model variants, disabled refresh, extra_fields) | `spec/devise/api/responses/*_spec.rb` | ✅ | +| **Service objects** (monad contract: `Success`/`Failure` per branch) | `spec/services/**` | ✅ | +| Token model (`active?`, expiry incl. `expires_in_infinite`, generator collision retry, conditional validations) | `spec/devise/api/token_spec.rb` | ✅ | +| Controller helpers (extraction rescue, invalid location `ArgumentError`, unmemoized refresh-token lookup) | `spec/devise/api/controllers/helpers_spec.rb` | ✅ | +| Generator (migration template, locale copy) | `spec/devise/api/generators/install_generator_spec.rb` | ✅ | ## Conventions for new specs diff --git a/lib/devise/api/responses/token_response.rb b/lib/devise/api/responses/token_response.rb index 36e4107..c371e97 100644 --- a/lib/devise/api/responses/token_response.rb +++ b/lib/devise/api/responses/token_response.rb @@ -66,7 +66,11 @@ def status def signed_up_body return default_body unless resource_owner.class.supported_devise_modules.confirmable? - message = resource_owner.confirmed? ? nil : I18n.t('devise.api.error_response.registerable.signed_up_but_unconfirmed') + message = if resource_owner.confirmed? + nil + else + I18n.t('devise.api.error_response.registerable.signed_up_but_unconfirmed') + end default_body.merge(confirmable: { confirmed: resource_owner.confirmed?, message: message }.compact) end diff --git a/spec/devise/api/configuration_spec.rb b/spec/devise/api/configuration_spec.rb index 7decff4..a67d4a2 100644 --- a/spec/devise/api/configuration_spec.rb +++ b/spec/devise/api/configuration_spec.rb @@ -122,4 +122,33 @@ end end end + + context 'overridden settings' do + before do + config.config.access_token.expires_in = 2.hours + config.config.access_token.generator = proc { |_resource_owner| 'custom token' } + config.config.refresh_token.enabled = false + config.config.sign_up.enabled = false + config.config.sign_up.extra_fields = [:name] + config.config.authorization.location = :header + end + + it 'reflects the overridden values' do + expect(config.access_token.expires_in).to eq 2.hours + expect(config.access_token.generator.call).to eq 'custom token' + expect(config.refresh_token.enabled).to eq false + expect(config.sign_up.enabled).to eq false + expect(config.sign_up.extra_fields).to eq [:name] + expect(config.authorization.location).to eq :header + end + + it 'does not affect other instances' do + other_config = described_class.new + + expect(other_config.access_token.expires_in).to eq 1.hour + expect(other_config.refresh_token.enabled).to eq true + expect(other_config.sign_up.enabled).to eq true + expect(other_config.authorization.location).to eq :both + end + end end diff --git a/spec/devise/api/controllers/helpers_spec.rb b/spec/devise/api/controllers/helpers_spec.rb new file mode 100644 index 0000000..496a94a --- /dev/null +++ b/spec/devise/api/controllers/helpers_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Devise::Api::Controllers::Helpers do + let(:host_class) do + Class.new do + include Devise::Api::Controllers::Helpers + + attr_accessor :request, :params + end + end + let(:host) { host_class.new } + + describe '#current_devise_api_refresh_token' do + context 'when the extracted token matches a refresh token' do + let(:devise_api_token) { create(:devise_api_token) } + + before do + allow(host).to receive(:find_devise_api_token).and_return(devise_api_token.refresh_token) + end + + it 'returns the token record' do + expect(host.current_devise_api_refresh_token).to eq(devise_api_token) + end + end + + context 'when no token can be extracted' do + before do + allow(host).to receive(:find_devise_api_token).and_return(nil) + end + + it 'returns nil' do + expect(host.current_devise_api_refresh_token).to be_nil + end + end + end + + describe '#current_devise_api_user' do + context 'when the extracted token matches an access token' do + let(:devise_api_token) { create(:devise_api_token) } + + before do + allow(host).to receive(:find_devise_api_token).and_return(devise_api_token.access_token) + end + + it 'returns the resource owner' do + expect(host.current_devise_api_user).to eq(devise_api_token.resource_owner) + end + end + + context 'when no token can be extracted' do + before do + allow(host).to receive(:find_devise_api_token).and_return(nil) + end + + it 'returns nil' do + expect(host.current_devise_api_user).to be_nil + end + end + end + + describe '#extract_devise_api_token_from_headers' do + context 'when stripping the authorization scheme raises an error' do + let(:token) { double('token', blank?: false) } + + before do + host.request = double('request', headers: { 'Authorization' => token }) + + allow(token).to receive(:gsub).and_raise(StandardError) + end + + it 'returns the raw token' do + expect(host.send(:extract_devise_api_token_from_headers)).to eq(token) + end + end + end + + describe '#find_devise_api_token' do + context 'when the authorization location is invalid' do + before do + allow(Devise.api.config.authorization).to receive(:location).and_return(:invalid) + end + + it 'raises an ArgumentError' do + expect { host.send(:find_devise_api_token) } + .to raise_error(ArgumentError, 'Invalid authorization location, must be :header, :params or :both') + end + end + end +end diff --git a/spec/devise/api/generators/install_generator_spec.rb b/spec/devise/api/generators/install_generator_spec.rb new file mode 100644 index 0000000..54c88c5 --- /dev/null +++ b/spec/devise/api/generators/install_generator_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +RSpec.describe Devise::Api::Generators::InstallGenerator do + describe '.next_migration_number' do + it 'returns a migration number in the timestamp format' do + expect(described_class.next_migration_number('db/migrate')).to match(/\A\d{14}\z/) + end + end + + describe '#install' do + let(:destination) { Dir.mktmpdir } + let(:migration_paths) { Dir.glob(File.join(destination, 'db/migrate/*_create_devise_api_tables.rb')) } + let(:locale_path) { File.join(destination, 'config/locales/devise_api.en.yml') } + + before do + described_class.start(['--quiet'], destination_root: destination) + end + + after do + FileUtils.remove_entry(destination) + end + + context 'migration template' do + it 'creates the migration' do + expect(migration_paths.size).to eq 1 + end + + it 'renders the migration for the current Active Record version' do + migration_version = "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" + + expect(File.read(migration_paths.first)).to include("ActiveRecord::Migration#{migration_version}") + end + + it 'creates the devise api tokens table' do + expect(File.read(migration_paths.first)).to include('create_table :devise_api_tokens') + end + end + + context 'locale file' do + it 'copies the locale file' do + expect(File.exist?(locale_path)).to eq true + end + + it 'copies the gem locale content' do + gem_locale_path = File.expand_path('../../../../config/locales/en.yml', __dir__) + + expect(File.read(locale_path)).to eq File.read(gem_locale_path) + end + end + end +end diff --git a/spec/devise/api/responses/error_response_spec.rb b/spec/devise/api/responses/error_response_spec.rb index 0e07439..38276a5 100644 --- a/spec/devise/api/responses/error_response_spec.rb +++ b/spec/devise/api/responses/error_response_spec.rb @@ -334,6 +334,49 @@ end end + context 'with a locked resource owner' do + let(:record) { create(:user) } + let(:error_response) do + described_class.new(nil, error: :invalid_authentication, record: record, resource_class: User) + end + + before do + record.lock_access! + end + + it 'has a body with the locked error description and the unlock information' do + expect(error_response.body).to eq( + error: :invalid_authentication, + error_description: [I18n.t('devise.api.error_response.lockable.locked')], + lockable: { + locked: true, + max_attempts: ::Devise.maximum_attempts, + failed_attemps: record.failed_attempts, + locked_at: record.locked_at, + unlock_at: record.locked_at + ::Devise.unlock_in + }, + confirmable: { + confirmed: false, + confirmation_sent_at: record.confirmation_sent_at + } + ) + end + end + + context 'with a resource class without lockable and confirmable' do + let(:record) { create(:admin_user) } + let(:error_response) do + described_class.new(nil, error: :invalid_authentication, record: record, resource_class: AdminUser) + end + + it 'has a body without lockable and confirmable info' do + expect(error_response.body).to eq( + error: :invalid_authentication, + error_description: [I18n.t('devise.api.error_response.invalid_authentication')] + ) + end + end + context 'with confirmable' do let(:record) { double('record', confirmed?: false, confirmation_sent_at: nil) } let(:resource_class) { double('resource_class', supported_devise_modules: [:confirmable]) } diff --git a/spec/devise/api/responses/token_response_spec.rb b/spec/devise/api/responses/token_response_spec.rb index 34302b3..6027a1c 100644 --- a/spec/devise/api/responses/token_response_spec.rb +++ b/spec/devise/api/responses/token_response_spec.rb @@ -121,6 +121,93 @@ end end + context 'sign up with an unconfirmed resource owner' do + let(:token_response) { described_class.new(nil, token: token, action: :sign_up) } + + it 'returns the unconfirmed message' do + allow(resource_owner).to receive(:confirmed?).and_return(false) + + expect(token_response.body[:confirmable]).to eq( + confirmed: false, + message: I18n.t('devise.api.error_response.registerable.signed_up_but_unconfirmed') + ) + end + end + + context 'sign up with a non confirmable resource owner' do + let(:resource_owner) do + FactoryBot.build( + :admin_user, + id: 1, + email: 'test@development.com', + created_at: Time.now, + updated_at: Time.now + ) + end + let(:token_response) { described_class.new(nil, token: token, action: :sign_up) } + + it 'returns the default body without confirmable info' do + expect(token_response.body).to eq({ + token: 'access_token', + refresh_token: 'refresh_token', + expires_in: 3600, + token_type: 'Bearer', + resource_owner: { + id: 1, + email: 'test@development.com', + created_at: resource_owner.created_at, + updated_at: resource_owner.updated_at + }.stringify_keys + }) + end + end + + context 'when refresh tokens are disabled' do + let(:token_response) { described_class.new(nil, token: token, action: :sign_in) } + + it 'does not return a refresh token' do + allow(Devise.api.config.refresh_token).to receive(:enabled).and_return(false) + + expect(token_response.body).to eq({ + token: 'access_token', + expires_in: 3600, + token_type: 'Bearer', + resource_owner: { + id: 1, + email: 'test@development.com', + created_at: resource_owner.created_at, + updated_at: resource_owner.updated_at + }.stringify_keys + }) + end + end + + context 'when sign up extra fields are configured' do + let(:resource_owner) do + FactoryBot.build( + :user, + id: 1, + email: 'test@development.com', + name: 'John Doe', + created_at: Time.now, + updated_at: Time.now + ) + end + let(:token_response) { described_class.new(nil, token: token, action: :sign_in) } + + it 'returns the extra fields on the resource owner' do + allow(Devise.api.config.sign_up).to receive(:extra_fields).and_return([:name]) + + expect(token_response.body[:resource_owner]).to eq({ + id: 1, + email: 'test@development.com', + created_at: resource_owner.created_at, + updated_at: resource_owner.updated_at, + name: 'John Doe' + }.stringify_keys) + end + end + context 'info' do let(:token_response) { described_class.new(nil, token: token, action: :info) } diff --git a/spec/devise/api/token_spec.rb b/spec/devise/api/token_spec.rb new file mode 100644 index 0000000..d9e4e31 --- /dev/null +++ b/spec/devise/api/token_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Devise::Api::Token do + describe '#active?' do + context 'when the token is not revoked and not expired' do + let(:devise_api_token) { create(:devise_api_token) } + + it 'returns true' do + expect(devise_api_token.active?).to eq true + expect(devise_api_token.inactive?).to eq false + end + end + + context 'when the token is revoked' do + let(:devise_api_token) { create(:devise_api_token, :revoked) } + + it 'returns false' do + expect(devise_api_token.active?).to eq false + expect(devise_api_token.inactive?).to eq true + end + end + + context 'when the token is expired' do + let(:devise_api_token) { create(:devise_api_token, :access_token_expired) } + + it 'returns false' do + expect(devise_api_token.active?).to eq false + expect(devise_api_token.inactive?).to eq true + end + end + end + + describe '#expired?' do + context 'when the access token lifetime has passed' do + let(:devise_api_token) { create(:devise_api_token, :access_token_expired) } + + it 'returns true' do + expect(devise_api_token.expired?).to eq true + end + end + + context 'when the access token lifetime has not passed' do + let(:devise_api_token) { create(:devise_api_token) } + + it 'returns false' do + expect(devise_api_token.expired?).to eq false + end + end + + context 'when the access token lifetime is infinite' do + let(:devise_api_token) { create(:devise_api_token, :access_token_expired) } + + before do + allow(Devise.api.config.access_token).to receive(:expires_in_infinite) + .and_return(proc { |_resource_owner| true }) + end + + it 'returns false' do + expect(devise_api_token.expired?).to eq false + end + end + end + + describe '#refresh_token_expired?' do + context 'when the refresh token lifetime has passed' do + let(:devise_api_token) { create(:devise_api_token, :refresh_token_expired) } + + it 'returns true' do + expect(devise_api_token.refresh_token_expired?).to eq true + end + end + + context 'when the refresh token lifetime has not passed' do + let(:devise_api_token) { create(:devise_api_token) } + + it 'returns false' do + expect(devise_api_token.refresh_token_expired?).to eq false + end + end + + context 'when the refresh token lifetime is infinite' do + let(:devise_api_token) { create(:devise_api_token, :refresh_token_expired) } + + before do + allow(Devise.api.config.refresh_token).to receive(:expires_in_infinite) + .and_return(proc { |_resource_owner| true }) + end + + it 'returns false' do + expect(devise_api_token.refresh_token_expired?).to eq false + end + end + end + + describe '.generate_uniq_access_token' do + context 'when the first generated token is already taken' do + let(:user) { create(:user) } + let(:existing_devise_api_token) { create(:devise_api_token) } + let(:generator) { double } + + before do + allow(generator).to receive(:call).and_return(existing_devise_api_token.access_token, 'uniq access token') + allow(Devise.api.config.access_token).to receive(:generator).and_return(generator) + end + + it 'retries until the token is uniq' do + expect(described_class.generate_uniq_access_token(user)).to eq('uniq access token') + expect(generator).to have_received(:call).twice + end + end + end + + describe '.generate_uniq_refresh_token' do + context 'when the first generated token is already taken' do + let(:user) { create(:user) } + let(:existing_devise_api_token) { create(:devise_api_token) } + let(:generator) { double } + + before do + allow(generator).to receive(:call).and_return(existing_devise_api_token.refresh_token, 'uniq refresh token') + allow(Devise.api.config.refresh_token).to receive(:generator).and_return(generator) + end + + it 'retries until the token is uniq' do + expect(described_class.generate_uniq_refresh_token(user)).to eq('uniq refresh token') + expect(generator).to have_received(:call).twice + end + end + + context 'when refresh tokens are disabled' do + let(:user) { create(:user) } + + before do + allow(Devise.api.config.refresh_token).to receive(:enabled).and_return(false) + end + + it 'returns nil' do + expect(described_class.generate_uniq_refresh_token(user)).to be_nil + end + end + end + + describe 'validations' do + context 'when refresh tokens are enabled' do + let(:devise_api_token) { build(:devise_api_token, refresh_token: nil) } + + it 'requires a refresh token' do + expect(devise_api_token.valid?).to eq false + expect(devise_api_token.errors[:refresh_token]).to be_present + end + end + + context 'when refresh tokens are disabled' do + let(:devise_api_token) { build(:devise_api_token, refresh_token: nil) } + + before do + allow(Devise.api.config.refresh_token).to receive(:enabled).and_return(false) + end + + it 'does not require a refresh token' do + expect(devise_api_token.valid?).to eq true + end + end + + context 'when the access token lifetime is infinite' do + let(:devise_api_token) { build(:devise_api_token, expires_in: nil) } + + before do + allow(Devise.api.config.access_token).to receive(:expires_in_infinite) + .and_return(proc { |_resource_owner| true }) + end + + it 'does not require an expires_in' do + expect(devise_api_token.valid?).to eq true + end + end + + context 'when the access token lifetime is not infinite' do + let(:devise_api_token) { build(:devise_api_token, expires_in: nil) } + + it 'requires an expires_in' do + expect(devise_api_token.valid?).to eq false + expect(devise_api_token.errors[:expires_in]).to be_present + end + end + end +end diff --git a/spec/dummy/app/models/admin_user.rb b/spec/dummy/app/models/admin_user.rb new file mode 100644 index 0000000..51c760c --- /dev/null +++ b/spec/dummy/app/models/admin_user.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class AdminUser < ApplicationRecord + # A bare devise model without the optional trackable, lockable and confirmable modules + devise :database_authenticatable, :registerable, :validatable, :api +end diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 867ee7d..4351e49 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -7,5 +7,6 @@ # root "articles#index" devise_for :users + devise_for :admin_users get :home, to: 'home#index' end diff --git a/spec/dummy/db/migrate/20260825000001_add_name_to_users.rb b/spec/dummy/db/migrate/20260825000001_add_name_to_users.rb new file mode 100644 index 0000000..569afe7 --- /dev/null +++ b/spec/dummy/db/migrate/20260825000001_add_name_to_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddNameToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :name, :string + end +end diff --git a/spec/dummy/db/migrate/20260825000002_create_admin_users.rb b/spec/dummy/db/migrate/20260825000002_create_admin_users.rb new file mode 100644 index 0000000..fec6110 --- /dev/null +++ b/spec/dummy/db/migrate/20260825000002_create_admin_users.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateAdminUsers < ActiveRecord::Migration[7.0] + def change + create_table :admin_users do |t| + ## Database authenticatable + t.string :email, null: false, default: '' + t.string :encrypted_password, null: false, default: '' + + t.timestamps null: false + end + + add_index :admin_users, :email, unique: true + end +end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index d884521..67b89b5 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -12,7 +12,15 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 20_230_113_213_619) do +ActiveRecord::Schema[7.0].define(version: 20_260_825_000_002) do + create_table 'admin_users', force: :cascade do |t| + t.string 'email', default: '', null: false + t.string 'encrypted_password', default: '', null: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.index ['email'], name: 'index_admin_users_on_email', unique: true + end + create_table 'devise_api_tokens', force: :cascade do |t| t.string 'resource_owner_type', null: false t.integer 'resource_owner_id', null: false @@ -47,6 +55,7 @@ t.integer 'failed_attempts', default: 0, null: false t.string 'unlock_token' t.datetime 'locked_at' + t.string 'name' t.datetime 'created_at', null: false t.datetime 'updated_at', null: false t.index ['confirmation_token'], name: 'index_users_on_confirmation_token', unique: true diff --git a/spec/factories/admin_users_factory.rb b/spec/factories/admin_users_factory.rb new file mode 100644 index 0000000..8e32646 --- /dev/null +++ b/spec/factories/admin_users_factory.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :admin_user do + email { Faker::Internet.email } + password { Faker::Internet.password } + end +end diff --git a/spec/requests/admin_user_tokens_spec.rb b/spec/requests/admin_user_tokens_spec.rb new file mode 100644 index 0000000..635bc6a --- /dev/null +++ b/spec/requests/admin_user_tokens_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Devise::Api::TokensController, type: :request do + describe 'POST /admin_users/tokens/sign_up' do + context 'when the admin user is valid' do + let(:params) { attributes_for(:admin_user) } + + before do + post sign_up_admin_user_tokens_path, params: params, as: :json + end + + it 'returns http success' do + expect(response).to have_http_status(:created) + end + + it 'returns a token' do + expect(parsed_body.token).to be_present + expect(parsed_body.refresh_token).to be_present + expect(parsed_body.expires_in).to eq(1.hour.to_i) + expect(parsed_body.token_type).to eq('Bearer') + expect(parsed_body.resource_owner.id).to eq(AdminUser.last.id) + expect(parsed_body.resource_owner.email).to eq(params[:email]) + end + + it 'does not return confirmable info' do + expect(parsed_body.confirmable).to be_nil + end + + it 'creates an admin user' do + expect(AdminUser.count).to eq(1) + expect(AdminUser.last.email).to eq(params[:email]) + end + + it 'creates a token' do + expect(Devise::Api::Token.count).to eq(1) + expect(Devise::Api::Token.last.resource_owner_type).to eq('AdminUser') + end + end + end + + describe 'POST /admin_users/tokens/sign_in' do + context 'when the credentials are valid' do + let(:admin_user) { create(:admin_user, password: 'pass123456') } + let(:params) { { email: admin_user.email, password: 'pass123456' } } + + before do + post sign_in_admin_user_tokens_path, params: params, as: :json + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'returns a token' do + expect(parsed_body.token).to be_present + expect(parsed_body.resource_owner.id).to eq(admin_user.id) + expect(parsed_body.resource_owner.email).to eq(admin_user.email) + end + end + + context 'when the password is invalid' do + let(:admin_user) { create(:admin_user) } + let(:params) { { email: admin_user.email, password: 'wrong password' } } + + before do + post sign_in_admin_user_tokens_path, params: params, as: :json + end + + it 'returns http unauthorized' do + expect(response).to have_http_status(:unauthorized) + end + + it 'returns an error response without lockable and confirmable info' do + expect(parsed_body.error).to eq 'invalid_authentication' + expect(parsed_body.error_description).to eq([I18n.t('devise.api.error_response.invalid_authentication')]) + expect(parsed_body.lockable).to be_nil + expect(parsed_body.confirmable).to be_nil + end + + it 'does not create a token' do + expect(Devise::Api::Token.count).to eq(0) + end + end + end +end diff --git a/spec/requests/configuration_overrides_spec.rb b/spec/requests/configuration_overrides_spec.rb new file mode 100644 index 0000000..2363cf6 --- /dev/null +++ b/spec/requests/configuration_overrides_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Devise.api.config is a global, so every override here is assigned inside an around +# block and restored in its ensure clause to avoid leaking into other examples. +RSpec.describe Devise::Api::TokensController, type: :request do + describe 'POST /users/tokens/sign_up' do + context 'when sign up is disabled' do + let(:params) { attributes_for(:user) } + + around do |example| + original = Devise.api.config.sign_up.enabled + Devise.api.config.sign_up.enabled = false + example.run + ensure + Devise.api.config.sign_up.enabled = original + end + + before do + post sign_up_user_tokens_path, params: params, as: :json + end + + it 'returns http bad request' do + expect(response).to have_http_status(:bad_request) + end + + it 'returns an error response' do + expect(parsed_body.error).to eq 'sign_up_disabled' + expect(parsed_body.error_description).to eq([I18n.t('devise.api.error_response.sign_up_disabled')]) + end + + it 'does not create a user' do + expect(User.count).to eq(0) + end + end + + context 'when extra fields are configured' do + let(:params) { attributes_for(:user).merge(name: 'John Doe') } + + around do |example| + original = Devise.api.config.sign_up.extra_fields + Devise.api.config.sign_up.extra_fields = [:name] + example.run + ensure + Devise.api.config.sign_up.extra_fields = original + end + + before do + post sign_up_user_tokens_path, params: params, as: :json + end + + it 'returns http success' do + expect(response).to have_http_status(:created) + end + + it 'returns the extra fields on the resource owner' do + expect(parsed_body.resource_owner.name).to eq('John Doe') + end + + it 'persists the extra fields' do + expect(User.last.name).to eq('John Doe') + end + end + end + + describe 'POST /users/tokens/refresh' do + context 'when refresh tokens are disabled' do + let(:user) { create(:user) } + let(:devise_api_token) { create(:devise_api_token, resource_owner: user) } + + around do |example| + original = Devise.api.config.refresh_token.enabled + Devise.api.config.refresh_token.enabled = false + example.run + ensure + Devise.api.config.refresh_token.enabled = original + end + + before do + post refresh_user_tokens_path, + headers: authentication_headers_for(user, devise_api_token, :refresh_token), + as: :json + end + + it 'returns http bad request' do + expect(response).to have_http_status(:bad_request) + end + + it 'returns an error response' do + expect(parsed_body.error).to eq 'refresh_token_disabled' + expect(parsed_body.error_description).to eq([I18n.t('devise.api.error_response.refresh_token_disabled')]) + end + end + end + + describe 'POST /users/tokens/revoke' do + context 'when the revoke service fails' do + let(:user) { create(:user) } + let(:devise_api_token) { create(:devise_api_token, resource_owner: user) } + let(:failure) do + Dry::Monads::Result::Failure.new(error: :devise_api_token_revoke_error, record: devise_api_token) + end + + before do + service = instance_double(Devise::Api::TokensService::Revoke, call: failure) + allow(Devise::Api::TokensService::Revoke).to receive(:new).and_return(service) + + post revoke_user_tokens_path, headers: authentication_headers_for(user, devise_api_token), as: :json + end + + it 'returns http unprocessable entity' do + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns an error response' do + expect(parsed_body.error).to eq 'devise_api_token_revoke_error' + end + + it 'does not revoke the token' do + expect(devise_api_token.reload.revoked?).to eq false + end + end + end + + describe 'GET /home' do + let(:user) { create(:user) } + let(:devise_api_token) { create(:devise_api_token, resource_owner: user) } + + context 'when the authorization location is :header' do + around do |example| + original = Devise.api.config.authorization.location + Devise.api.config.authorization.location = :header + example.run + ensure + Devise.api.config.authorization.location = original + end + + context 'and the token is on the header' do + before do + get home_path, headers: authentication_headers_for(user, devise_api_token), as: :json + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + end + + context 'and the token is on the url param' do + before do + get home_path(access_token: devise_api_token.access_token), as: :json + end + + it 'returns http unauthorized' do + expect(response).to have_http_status(:unauthorized) + end + + it 'returns an error response' do + expect(parsed_body.error).to eq 'invalid_token' + end + end + end + + context 'when the authorization location is :params' do + around do |example| + original = Devise.api.config.authorization.location + Devise.api.config.authorization.location = :params + example.run + ensure + Devise.api.config.authorization.location = original + end + + context 'and the token is on the url param' do + before do + get home_path(access_token: devise_api_token.access_token), as: :json + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + end + + context 'and the token is on the header' do + before do + get home_path, headers: authentication_headers_for(user, devise_api_token), as: :json + end + + it 'returns http unauthorized' do + expect(response).to have_http_status(:unauthorized) + end + + it 'returns an error response' do + expect(parsed_body.error).to eq 'invalid_token' + end + end + end + end +end diff --git a/spec/routing/customized_path_names_routes_spec.rb b/spec/routing/customized_path_names_routes_spec.rb new file mode 100644 index 0000000..83f5090 --- /dev/null +++ b/spec/routing/customized_path_names_routes_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Customized path names routes' do + # create and customize routes for devise_for :users + before :all do + Rails.application.routes.disable_clear_and_finalize = true + + Rails.application.routes.clear! + + Rails.application.routes.draw do + devise_for :users, path_names: { tokens: 'sessions' } + end + end + + after :all do + Rails.application.routes.clear! + + load File.expand_path('../dummy/config/routes.rb', __dir__) + end + + it 'routes to /users/sessions/refresh' do + expect(post: '/users/sessions/refresh').to route_to('devise/api/tokens#refresh') + end + + it 'routes to /users/sessions/revoke' do + expect(post: '/users/sessions/revoke').to route_to('devise/api/tokens#revoke') + end + + it 'routes to /users/sessions/info' do + expect(get: '/users/sessions/info').to route_to('devise/api/tokens#info') + end + + it 'routes to /users/sessions/sign_in' do + expect(post: '/users/sessions/sign_in').to route_to('devise/api/tokens#sign_in') + end + + it 'routes to /users/sessions/sign_up' do + expect(post: '/users/sessions/sign_up').to route_to('devise/api/tokens#sign_up') + end +end diff --git a/spec/services/resource_owner_service/authenticate_spec.rb b/spec/services/resource_owner_service/authenticate_spec.rb index b640a6e..16468ab 100644 --- a/spec/services/resource_owner_service/authenticate_spec.rb +++ b/spec/services/resource_owner_service/authenticate_spec.rb @@ -6,4 +6,60 @@ it 'inherits from Devise::Api::BaseService' do expect(described_class).to be < Devise::Api::BaseService end + + describe '#call' do + let(:result) { described_class.new(params: params, resource_class: User).call } + + context 'when no resource owner matches the authentication keys' do + let(:params) { { email: 'unknown@development.com', password: 'pass123456' } } + + it 'returns a failure' do + expect(result).to be_failure + expect(result.failure).to eq(error: :invalid_email, record: nil) + end + end + + context 'when the password is wrong' do + let(:user) { create(:user) } + let(:params) { { email: user.email, password: 'wrong password' } } + + before do + user.confirm + end + + it 'returns a failure with the resource owner' do + expect(result).to be_failure + expect(result.failure).to eq(error: :invalid_authentication, record: user) + end + end + + context 'when the resource owner is not active for authentication' do + let(:user) { create(:user, password: 'pass123456') } + let(:params) { { email: user.email, password: 'pass123456' } } + + before do + user.confirm + user.lock_access! + end + + it 'returns a failure with the resource owner' do + expect(result).to be_failure + expect(result.failure).to eq(error: :invalid_authentication, record: user) + end + end + + context 'when the credentials are valid' do + let(:user) { create(:user, password: 'pass123456') } + let(:params) { { email: user.email, password: 'pass123456' } } + + before do + user.confirm + end + + it 'returns a success with the resource owner' do + expect(result).to be_success + expect(result.success).to eq(user) + end + end + end end diff --git a/spec/services/resource_owner_service/sign_in_spec.rb b/spec/services/resource_owner_service/sign_in_spec.rb index c0cd548..ffa550b 100644 --- a/spec/services/resource_owner_service/sign_in_spec.rb +++ b/spec/services/resource_owner_service/sign_in_spec.rb @@ -6,4 +6,60 @@ it 'inherits from Devise::Api::BaseService' do expect(described_class).to be < Devise::Api::BaseService end + + describe '#call' do + context 'when the authentication fails' do + let(:user) { create(:user) } + let(:result) do + described_class.new(params: { email: user.email, password: 'wrong password' }, resource_class: User).call + end + + it 'returns a failure' do + expect(result).to be_failure + expect(result.failure).to eq(error: :invalid_authentication, record: user) + end + + it 'does not create a token' do + result + + expect(Devise::Api::Token.count).to eq(0) + end + end + + context 'when the resource owner is lockable' do + let(:user) { create(:user, password: 'pass123456') } + let(:result) do + described_class.new(params: { email: user.email, password: 'pass123456' }, resource_class: User).call + end + + before do + user.confirm + user.update(failed_attempts: 2) + end + + it 'returns a success with a token' do + expect(result).to be_success + expect(result.success.resource_owner).to eq(user) + end + + it 'resets the failed attempts' do + result + + expect(user.reload.failed_attempts).to eq(0) + end + end + + context 'when the resource owner is not lockable' do + let(:admin_user) { create(:admin_user, password: 'pass123456') } + let(:result) do + described_class.new(params: { email: admin_user.email, password: 'pass123456' }, + resource_class: AdminUser).call + end + + it 'returns a success with a token' do + expect(result).to be_success + expect(result.success.resource_owner).to eq(admin_user) + end + end + end end diff --git a/spec/services/resource_owner_service/sign_up_spec.rb b/spec/services/resource_owner_service/sign_up_spec.rb index a1a1403..3b809a7 100644 --- a/spec/services/resource_owner_service/sign_up_spec.rb +++ b/spec/services/resource_owner_service/sign_up_spec.rb @@ -6,4 +6,61 @@ it 'inherits from Devise::Api::BaseService' do expect(described_class).to be < Devise::Api::BaseService end + + describe '#call' do + let(:result) { described_class.new(params: params, resource_class: User).call } + + context 'when the resource owner is invalid' do + let(:user) { create(:user) } + let(:params) { attributes_for(:user, email: user.email) } + + it 'returns a failure with the invalid record' do + expect(result).to be_failure + expect(result.failure[:error]).to eq(:resource_owner_create_error) + expect(result.failure[:record].errors).to be_present + end + + it 'does not create a resource owner' do + result + + expect(User.count).to eq(1) + end + end + + context 'when the resource owner is valid' do + let(:params) { attributes_for(:user) } + + it 'returns a success with a token' do + expect(result).to be_success + expect(result.success).to be_persisted + expect(result.success.resource_owner.email).to eq(params[:email]) + end + + it 'creates the resource owner and the token' do + result + + expect(User.count).to eq(1) + expect(Devise::Api::Token.count).to eq(1) + end + end + + context 'when the token creation fails' do + let(:params) { attributes_for(:user) } + + before do + allow(Devise.api.config.access_token).to receive(:expires_in).and_return(nil) + end + + it 'returns a failure' do + expect(result).to be_failure + expect(result.failure[:error]).to eq(:devise_api_token_create_error) + end + + it 'rolls back the resource owner creation' do + result + + expect(User.count).to eq(0) + end + end + end end diff --git a/spec/services/tokens_service/create_spec.rb b/spec/services/tokens_service/create_spec.rb index 26de1a7..8a31aa6 100644 --- a/spec/services/tokens_service/create_spec.rb +++ b/spec/services/tokens_service/create_spec.rb @@ -6,4 +6,67 @@ it 'inherits from Devise::Api::BaseService' do expect(described_class).to be < Devise::Api::BaseService end + + describe '#call' do + let(:result) { described_class.new(resource_owner: resource_owner).call } + + context 'when the resource owner does not respond to access_tokens' do + let(:resource_owner) { nil } + + it 'returns a failure' do + expect(result).to be_failure + expect(result.failure).to eq(error: :invalid_resource_owner) + end + + it 'does not create a token' do + result + + expect(Devise::Api::Token.count).to eq(0) + end + end + + context 'when the resource owner is valid' do + let(:resource_owner) { create(:user) } + + it 'returns a success with a persisted token' do + expect(result).to be_success + expect(result.success).to be_persisted + expect(result.success.resource_owner).to eq(resource_owner) + expect(result.success.expires_in).to eq(Devise.api.config.access_token.expires_in.to_i) + expect(result.success.previous_refresh_token).to be_nil + end + end + + context 'when a previous refresh token is given' do + let(:resource_owner) { create(:user) } + let(:result) do + described_class.new(resource_owner: resource_owner, previous_refresh_token: 'previous token').call + end + + it 'returns a success with the previous refresh token set' do + expect(result).to be_success + expect(result.success.previous_refresh_token).to eq('previous token') + end + end + + context 'when the token cannot be saved' do + let(:resource_owner) { create(:user) } + + before do + allow(Devise.api.config.access_token).to receive(:expires_in).and_return(nil) + end + + it 'returns a failure with the invalid record' do + expect(result).to be_failure + expect(result.failure[:error]).to eq(:devise_api_token_create_error) + expect(result.failure[:record].errors).to be_present + end + + it 'does not create a token' do + result + + expect(Devise::Api::Token.count).to eq(0) + end + end + end end diff --git a/spec/services/tokens_service/refresh_spec.rb b/spec/services/tokens_service/refresh_spec.rb index d945a1f..278d402 100644 --- a/spec/services/tokens_service/refresh_spec.rb +++ b/spec/services/tokens_service/refresh_spec.rb @@ -6,4 +6,53 @@ it 'inherits from Devise::Api::BaseService' do expect(described_class).to be < Devise::Api::BaseService end + + describe '#call' do + let(:result) { described_class.new(devise_api_token: devise_api_token).call } + + context 'when the refresh token is expired' do + let(:devise_api_token) { create(:devise_api_token, :refresh_token_expired) } + + it 'returns a failure' do + expect(result).to be_failure + expect(result.failure).to eq(error: :expired_refresh_token) + end + + it 'does not create a token' do + result + + expect(Devise::Api::Token.count).to eq(1) + end + end + + context 'when the refresh token is valid' do + let(:devise_api_token) { create(:devise_api_token) } + + it 'returns a success with a new token' do + expect(result).to be_success + expect(result.success).to be_persisted + expect(result.success.previous_refresh_token).to eq(devise_api_token.refresh_token) + expect(result.success.resource_owner).to eq(devise_api_token.resource_owner) + end + + it 'creates a new token' do + result + + expect(Devise::Api::Token.count).to eq(2) + end + end + + context 'when the token creation fails' do + let(:devise_api_token) { create(:devise_api_token) } + + before do + allow(Devise.api.config.access_token).to receive(:expires_in).and_return(nil) + end + + it 'returns a failure' do + expect(result).to be_failure + expect(result.failure[:error]).to eq(:devise_api_token_create_error) + end + end + end end diff --git a/spec/services/tokens_service/revoke_spec.rb b/spec/services/tokens_service/revoke_spec.rb index a834b66..2995787 100644 --- a/spec/services/tokens_service/revoke_spec.rb +++ b/spec/services/tokens_service/revoke_spec.rb @@ -6,4 +6,59 @@ it 'inherits from Devise::Api::BaseService' do expect(described_class).to be < Devise::Api::BaseService end + + describe '#call' do + let(:result) { described_class.new(devise_api_token: devise_api_token).call } + + context 'when the token is blank' do + let(:devise_api_token) { nil } + + it 'returns a success without a token' do + expect(result).to be_success + expect(result.success).to be_nil + end + end + + context 'when the token is already revoked' do + let(:devise_api_token) { create(:devise_api_token, :revoked) } + + it 'returns a success without updating the token' do + revoked_at = devise_api_token.revoked_at + + expect(result).to be_success + expect(devise_api_token.reload.revoked_at).to eq(revoked_at) + end + end + + context 'when the token is already expired' do + let(:devise_api_token) { create(:devise_api_token, :access_token_expired) } + + it 'returns a success without revoking the token' do + expect(result).to be_success + expect(devise_api_token.reload.revoked_at).to be_nil + end + end + + context 'when the token is revokable' do + let(:devise_api_token) { create(:devise_api_token) } + + it 'returns a success and revokes the token' do + expect(result).to be_success + expect(devise_api_token.reload.revoked?).to eq true + end + end + + context 'when the update fails' do + let(:devise_api_token) { create(:devise_api_token) } + + before do + allow(devise_api_token).to receive(:update).and_return(false) + end + + it 'returns a failure with the token' do + expect(result).to be_failure + expect(result.failure).to eq(error: :devise_api_token_revoke_error, record: devise_api_token) + end + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c002cd5..84822f7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,6 +4,20 @@ $LOAD_PATH.unshift File.dirname(__FILE__) +# SimpleCov must start before the gem and the dummy app are required so their files are tracked +require 'simplecov' + +SimpleCov.start do + enable_coverage :branch + add_filter %r{^/spec/} + add_filter 'lib/devise/api/version.rb' # loaded by the gemspec before SimpleCov starts + track_files '{app,lib}/**/*.rb' + add_group 'Controllers', 'app/controllers' + add_group 'Services', 'app/services' + add_group 'Lib', 'lib' + minimum_coverage line: 95 if ENV['CI'] || ENV['ENFORCE_COVERAGE'] +end + require 'devise/api' require 'dummy/config/environment' require 'pry'