diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index 0a87bf43e..121fded34 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -1,12 +1,44 @@ # frozen_string_literal: true class Api::V1::UsersController < Api::V1::BaseController - before_action :ensure_write_scope - before_action :ensure_admin, only: :reset + before_action :ensure_read_scope, only: :reset_status + before_action :ensure_write_scope, except: :reset_status + before_action :ensure_admin, only: %i[reset reset_status] def reset - FamilyResetJob.perform_later(Current.family) - render json: { message: "Account reset has been initiated" } + family = current_resource_owner.family + begin + job = FamilyResetJob.perform_later(family) + rescue StandardError => e + Rails.logger.error "Failed to enqueue FamilyResetJob for family #{family.id}: #{e.message}" + + render json: { + error: "reset_enqueue_failed", + message: "Account reset could not be queued" + }, status: :internal_server_error + return + end + + render json: { + message: "Account reset has been initiated", + status: "queued", + job_id: job.job_id, + family_id: family.id, + status_url: api_v1_users_reset_status_path + } + end + + def reset_status + family = current_resource_owner.family + counts = reset_target_counts(family) + reset_complete = counts.values.sum.zero? + + render json: { + status: reset_complete ? "complete" : "data_remaining", + family_id: family.id, + reset_complete: reset_complete, + counts: counts + } end def destroy @@ -26,10 +58,26 @@ class Api::V1::UsersController < Api::V1::BaseController authorize_scope!(:write) end + def ensure_read_scope + authorize_scope!(:read) + end + def ensure_admin return true if current_resource_owner&.admin? - render_json({ error: "forbidden", message: I18n.t("users.reset.unauthorized") }, status: :forbidden) + render_json({ error: "forbidden", message: "You are not authorized to perform this action" }, status: :forbidden) false end + + def reset_target_counts(family) + { + accounts: family.accounts.count, + categories: family.categories.count, + tags: family.tags.count, + merchants: family.merchants.count, + plaid_items: family.plaid_items.count, + imports: family.imports.count, + budgets: family.budgets.count + } + end end diff --git a/config/routes.rb b/config/routes.rb index 6f91f4130..d20e66719 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -441,6 +441,7 @@ Rails.application.routes.draw do end end + get "users/reset/status", to: "users#reset_status" delete "users/reset", to: "users#reset" delete "users/me", to: "users#destroy" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 46e6650f9..da0204a01 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1259,6 +1259,85 @@ components: properties: message: type: string + ResetInitiatedResponse: + type: object + required: + - message + - status + - job_id + - family_id + - status_url + properties: + message: + type: string + status: + type: string + enum: + - queued + job_id: + type: string + description: Informational Active Job identifier returned by the queue adapter; + reset status is family-scoped, not job-scoped. + family_id: + type: string + format: uuid + description: UUID of the family being reset. + status_url: + type: string + ResetStatusResponse: + type: object + required: + - status + - family_id + - reset_complete + - counts + properties: + status: + type: string + enum: + - complete + - data_remaining + description: Counts-based family reset status at response time. + family_id: + type: string + format: uuid + description: UUID of the family whose reset target counts were checked. + reset_complete: + type: boolean + description: True when all reset target counts are zero at response time. + This is a family data snapshot, not a durable per-job completion record. + counts: + type: object + required: + - accounts + - categories + - tags + - merchants + - plaid_items + - imports + - budgets + properties: + accounts: + type: integer + minimum: 0 + categories: + type: integer + minimum: 0 + tags: + type: integer + minimum: 0 + merchants: + type: integer + minimum: 0 + plaid_items: + type: integer + minimum: 0 + imports: + type: integer + minimum: 0 + budgets: + type: integer + minimum: 0 paths: "/api/v1/accounts": get: @@ -3752,7 +3831,8 @@ paths: - Users description: Resets all financial data (accounts, categories, merchants, tags, etc.) for the current user's family while keeping the user account intact. - The reset runs asynchronously in the background. Requires admin role. + The reset runs asynchronously in the background. The returned job_id is informational + only; reset status is family-scoped, not job-scoped. Requires admin role. security: - apiKeyAuth: [] responses: @@ -3761,11 +3841,55 @@ paths: content: application/json: schema: - "$ref": "#/components/schemas/SuccessMessage" + "$ref": "#/components/schemas/ResetInitiatedResponse" '401': description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" '403': description: forbidden - requires read_write scope and admin role + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '500': + description: reset enqueue failed + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/users/reset/status": + get: + summary: Retrieve reset status + tags: + - Users + description: Returns counts of family-owned data targeted by account reset. + Use this after DELETE /api/v1/users/reset to decide whether reset materialization + has completed. Completion is a counts-based family snapshot and may change + if new data is created after reset. + security: + - apiKeyAuth: [] + responses: + '200': + description: reset status returned + content: + application/json: + schema: + "$ref": "#/components/schemas/ResetStatusResponse" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: forbidden - requires admin role + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/users/me": delete: summary: Delete account diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb index bb714d8ef..17c831fe9 100644 --- a/spec/requests/api/v1/users_spec.rb +++ b/spec/requests/api/v1/users_spec.rb @@ -42,23 +42,28 @@ RSpec.describe 'API V1 Users', type: :request do description 'Resets all financial data (accounts, categories, merchants, tags, etc.) ' \ 'for the current user\'s family while keeping the user account intact. ' \ 'The reset runs asynchronously in the background. ' \ + 'The returned job_id is informational only; reset status is family-scoped, not job-scoped. ' \ 'Requires admin role.' security [ { apiKeyAuth: [] } ] produces 'application/json' response '200', 'account reset initiated' do - schema '$ref' => '#/components/schemas/SuccessMessage' + schema '$ref' => '#/components/schemas/ResetInitiatedResponse' run_test! end response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + let(:'X-Api-Key') { 'invalid-key' } run_test! end response '403', 'forbidden - requires read_write scope and admin role' do + schema '$ref' => '#/components/schemas/ErrorResponse' + let(:api_key) do key = ApiKey.generate_secure_key ApiKey.create!( @@ -72,6 +77,49 @@ RSpec.describe 'API V1 Users', type: :request do run_test! end + + response '500', 'reset enqueue failed' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + before do + allow(FamilyResetJob).to receive(:perform_later).and_raise(StandardError, 'queue down') + end + + run_test! + end + end + end + + path '/api/v1/users/reset/status' do + get 'Retrieve reset status' do + tags 'Users' + description 'Returns counts of family-owned data targeted by account reset. ' \ + 'Use this after DELETE /api/v1/users/reset to decide whether reset materialization has completed. ' \ + 'Completion is a counts-based family snapshot and may change if new data is created after reset.' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'reset status returned' do + schema '$ref' => '#/components/schemas/ResetStatusResponse' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { 'invalid-key' } + + run_test! + end + + response '403', 'forbidden - requires admin role' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:role) { :member } + + run_test! + end end end @@ -114,6 +162,7 @@ RSpec.describe 'API V1 Users', type: :request do schema '$ref' => '#/components/schemas/ErrorResponse' before do + api_key allow_any_instance_of(User).to receive(:deactivate).and_return(false) allow_any_instance_of(User).to receive(:errors).and_return( double(full_messages: [ 'Cannot deactivate admin with other users' ]) diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 729b10a2b..ef49b39d6 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -727,6 +727,49 @@ RSpec.configure do |config| properties: { message: { type: :string } } + }, + ResetInitiatedResponse: { + type: :object, + required: %w[message status job_id family_id status_url], + properties: { + message: { type: :string }, + status: { type: :string, enum: %w[queued] }, + job_id: { + type: :string, + description: 'Informational Active Job identifier returned by the queue adapter; reset status is family-scoped, not job-scoped.' + }, + family_id: { type: :string, format: :uuid, description: 'UUID of the family being reset.' }, + status_url: { type: :string } + } + }, + ResetStatusResponse: { + type: :object, + required: %w[status family_id reset_complete counts], + properties: { + status: { + type: :string, + enum: %w[complete data_remaining], + description: 'Counts-based family reset status at response time.' + }, + family_id: { type: :string, format: :uuid, description: 'UUID of the family whose reset target counts were checked.' }, + reset_complete: { + type: :boolean, + description: 'True when all reset target counts are zero at response time. This is a family data snapshot, not a durable per-job completion record.' + }, + counts: { + type: :object, + required: %w[accounts categories tags merchants plaid_items imports budgets], + properties: { + accounts: { type: :integer, minimum: 0 }, + categories: { type: :integer, minimum: 0 }, + tags: { type: :integer, minimum: 0 }, + merchants: { type: :integer, minimum: 0 }, + plaid_items: { type: :integer, minimum: 0 }, + imports: { type: :integer, minimum: 0 }, + budgets: { type: :integer, minimum: 0 } + } + } + } } } } diff --git a/test/controllers/api/v1/users_controller_test.rb b/test/controllers/api/v1/users_controller_test.rb index 9a8be9d85..853348d49 100644 --- a/test/controllers/api/v1/users_controller_test.rb +++ b/test/controllers/api/v1/users_controller_test.rb @@ -12,6 +12,7 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest user: @user, name: "Test Read-Write Key", scopes: [ "read_write" ], + source: "web", display_key: "test_rw_#{SecureRandom.hex(8)}" ) @@ -56,6 +57,7 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest user: users(:family_member), name: "Member Read-Write Key", scopes: [ "read_write" ], + source: "web", display_key: "test_member_#{SecureRandom.hex(8)}" ) @@ -69,13 +71,65 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest end test "reset enqueues FamilyResetJob and returns 200" do - assert_enqueued_with(job: FamilyResetJob) do + assert_enqueued_with(job: FamilyResetJob, args: [ @user.family ]) do delete "/api/v1/users/reset", headers: api_headers(@api_key) end assert_response :ok body = JSON.parse(response.body) assert_equal "Account reset has been initiated", body["message"] + assert_equal "queued", body["status"] + assert_equal @user.family.id, body["family_id"] + assert body["job_id"].present? + assert_equal "/api/v1/users/reset/status", body["status_url"] + end + + test "reset returns controlled error when enqueue fails" do + FamilyResetJob.stub(:perform_later, ->(_family) { raise StandardError, "queue down" }) do + delete "/api/v1/users/reset", headers: api_headers(@api_key) + end + + assert_response :internal_server_error + body = JSON.parse(response.body) + assert_equal "reset_enqueue_failed", body["error"] + assert_equal "Account reset could not be queued", body["message"] + assert_not_includes response.body, "queue down" + end + + test "reset status requires authentication" do + get "/api/v1/users/reset/status" + assert_response :unauthorized + end + + test "reset status requires admin role" do + non_admin_api_key = ApiKey.create!( + user: users(:family_member), + name: "Member Read Key", + scopes: [ "read_write" ], + source: "web", + display_key: "test_member_read_#{SecureRandom.hex(8)}" + ) + + get "/api/v1/users/reset/status", headers: api_headers(non_admin_api_key) + + assert_response :forbidden + end + + test "reset status returns family data counts" do + get "/api/v1/users/reset/status", headers: api_headers(@read_only_api_key) + + assert_response :ok + body = JSON.parse(response.body) + assert_equal @user.family.id, body["family_id"] + assert_includes %w[complete data_remaining], body["status"] + assert_equal body["counts"].values.sum.zero?, body["reset_complete"] + assert body["counts"].key?("accounts") + assert body["counts"].key?("categories") + assert body["counts"].key?("tags") + assert body["counts"].key?("merchants") + assert body["counts"].key?("plaid_items") + assert body["counts"].key?("imports") + assert body["counts"].key?("budgets") end # -- Delete account -------------------------------------------------------- @@ -92,6 +146,7 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest user: solo_user, name: "Solo Key", scopes: [ "read_write" ], + source: "web", display_key: "test_solo_#{SecureRandom.hex(8)}" ) @@ -129,6 +184,6 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest private def api_headers(api_key) - { "X-Api-Key" => api_key.display_key } + { "X-Api-Key" => api_key.plain_key } end end