Files
sure/test/controllers/settings/api_keys_controller_test.rb
Will Wilson 9487e6cbfb Allow multiple active API keys per user (#2077)
* feat(api): allow multiple active API keys per user

Previously a user could hold only one active API key; creating a new one
silently revoked the existing key, breaking any app using it. Allow
multiple named active keys instead.

- Drop the one-active-key-per-source validation; add name uniqueness
  among the user's active visible keys (revoked names are reusable,
  same name allowed across users).
- Rewrite Settings::ApiKeysController as a RESTful collection
  (index/show/new/create/destroy); create no longer revokes existing
  keys, and key lookup is scoped to the current user's active keys.
- resource :api_key -> resources :api_keys.
- Add an API keys list view with per-key revoke and an empty state;
  rewrite the show page for a single key.
- Update i18n (remove single-key copy, pluralise nav label) and tests.

* refactor(api): address review on multiple API keys

- Remove unreachable destroy branches (cannot_revoke is guarded by the
  .visible 404; revoke! raises rather than returning false) and document
  that .visible is the demo-key revocation guard.
- Delete the orphaned created.html.erb / created.turbo_stream.erb
  templates (no action renders them) and their unused locale keys.
- Extract shared partials (_scope_badges, _status_indicator, _key_meta,
  _key_reveal, _usage) to de-duplicate the index and show views; unify
  the active-status indicator on the standard dot.
- Carry forward the @container / @lg:flex-row / min-w-0 responsive
  fixes from #2079 into the shared key-reveal partial.

* test(api): cover newly-created API key confirmation render

* refactor(api): harden demo-key guard and address review nits

- Document the demo-key revocation guard on ApiKey's `visible` scope (the
  authoritative spot) and add a model test locking the invariant that
  `.visible` excludes the demo monitoring key.
- _scope_badges: use an i18n lookup with a humanize fallback instead of
  bypassing translation for unknown scopes.
- _key_reveal: drop the hard-coded `id` from the shared partial; the
  system test now locates the key via its data-clipboard-target.

* refactor(api-keys): migrate hand-rolled badges to DS::Pill

Replace raw span elements in scope badges and status indicator
with DS::Pill to align with the design system migration convention.

* fix(loans): opening anchor now uses current balance, not original principal

When creating a loan manually, the opening anchor valuation was being
set to `initial_balance` (the original loan principal) instead of
`account.balance` (the current outstanding balance). After the sync
job ran, `account.balance` was overwritten to match the anchor,
making every manually-created loan show its original principal as
the current balance.

Fix by always using `account.balance` for the opening anchor in
`create_and_sync`, and reading `Loan#original_balance` from the
`loans.initial_balance` column directly (with a fallback to
`first_valuation_amount` for provider-synced loans that may not
have the column populated).

* fix(api-keys): strip accidental loan changes; rescue revoke! failures

The fix(loans) commit was accidentally committed into this branch.
Remove the loan-related changes from account.rb, loan.rb, and both
test files, restoring them to their pre-loan-commit state.

Also fix the destroy action: revoke! uses update! internally which
raises ActiveRecord::RecordInvalid on failure rather than returning
false. Add rescue for RecordInvalid and RecordNotDestroyed so failures
produce a flash alert instead of a 500. Re-adds the revoke_failed
locale key that was dropped from settings.api_keys.destroy.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-06-26 06:47:38 +02:00

204 lines
5.0 KiB
Ruby

require "test_helper"
class Settings::ApiKeysControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@user.api_keys.destroy_all # Ensure clean state
sign_in @user
end
test "index shows api keys list" do
ApiKey.create!(
user: @user,
name: "Listed Key",
display_key: "listed_key_123",
scopes: [ "read" ]
)
get settings_api_keys_path
assert_response :success
assert_includes response.body, "Listed Key"
end
test "new always renders form (no redirect when key exists)" do
ApiKey.create!(
user: @user,
name: "Existing API Key",
display_key: "existing_key_123",
scopes: [ "read" ]
)
get new_settings_api_key_path
assert_response :success
end
test "create makes a new key without revoking existing keys" do
existing = ApiKey.create!(
user: @user,
name: "Existing API Key",
display_key: "existing_key_123",
scopes: [ "read" ]
)
assert_difference "ApiKey.count", 1 do
post settings_api_keys_path, params: {
api_key: {
name: "Brand New Key",
scopes: "read_write"
}
}
end
new_key = @user.api_keys.active.visible.find_by(name: "Brand New Key")
assert new_key.present?
assert_redirected_to settings_api_key_path(new_key, newly_created: true)
existing.reload
refute existing.revoked?
assert_includes new_key.scopes, "read_write"
end
test "create rejects blank name" do
assert_no_difference "ApiKey.count" do
post settings_api_keys_path, params: {
api_key: {
name: "",
scopes: "read"
}
}
end
assert_response :unprocessable_entity
end
test "create rejects blank scopes" do
assert_no_difference "ApiKey.count" do
post settings_api_keys_path, params: {
api_key: {
name: "No Scopes Key",
scopes: []
}
}
end
assert_response :unprocessable_entity
end
test "create rejects duplicate active name" do
ApiKey.create!(
user: @user,
name: "Dup",
display_key: "dup_key_123",
scopes: [ "read" ]
)
assert_no_difference "ApiKey.count" do
post settings_api_keys_path, params: {
api_key: {
name: "Dup",
scopes: "read_write"
}
}
end
assert_response :unprocessable_entity
end
test "show renders a key" do
created_key = ApiKey.create!(
user: @user,
name: "Test API Key",
display_key: "test_key_123",
scopes: [ "read" ]
)
get settings_api_key_path(created_key)
assert_response :success
assert_includes response.body, "Test API Key"
end
test "show renders the newly created confirmation" do
created_key = ApiKey.create!(
user: @user,
name: "Fresh Key",
display_key: "fresh_key_123",
scopes: [ "read" ]
)
get settings_api_key_path(created_key, newly_created: true)
assert_response :success
assert_includes response.body, created_key.plain_key
assert_select "h3", text: I18n.t("settings.api_keys.show.newly_created.heading")
end
test "show 404s on another user's key" do
other_user = users(:family_member)
other_user.api_keys.destroy_all
other_key = ApiKey.create!(
user: other_user,
name: "Other User Key",
display_key: "other_user_key_123",
scopes: [ "read" ]
)
get settings_api_key_path(other_key)
assert_response :not_found
end
test "destroy revokes the targeted key only" do
key1 = ApiKey.create!(
user: @user,
name: "Key One",
display_key: "key_one_123",
scopes: [ "read" ]
)
key2 = ApiKey.create!(
user: @user,
name: "Key Two",
display_key: "key_two_123",
scopes: [ "read_write" ]
)
delete settings_api_key_path(key1)
assert_redirected_to settings_api_keys_path
key1.reload
key2.reload
assert key1.revoked?
refute key2.revoked?
end
test "destroy cannot revoke demo monitoring key" do
# set_api_key scopes to .visible which EXCLUDES the demo key, so the
# demo key id is not found by the controller and the request 404s
# before reaching the cannot_revoke branch.
demo_key = ApiKey.create!(
user: @user,
name: "Demo Monitoring Key",
display_key: ApiKey::DEMO_MONITORING_KEY,
scopes: [ "read" ]
)
delete settings_api_key_path(demo_key)
assert_response :not_found
demo_key.reload
refute demo_key.revoked?
end
test "create generates a secure random API key" do
post settings_api_keys_path, params: {
api_key: {
name: "Random Key Test",
scopes: "read"
}
}
created_key = @user.api_keys.active.visible.find_by(name: "Random Key Test")
assert created_key.present?
assert_redirected_to settings_api_key_path(created_key, newly_created: true)
assert_includes created_key.scopes, "read"
assert_equal 64, created_key.plain_key.length
end
end