mirror of
https://github.com/we-promise/sure.git
synced 2026-05-12 15:15:01 +00:00
feat(api): expose reset status polling (#1598)
* feat(api): expose reset status polling * fix(api): hide reset enqueue exception details * fix(api): use stable reset authorization message * fix(api): narrow reset enqueue error handling * fix(api): document reset enqueue failures * docs(api): regenerate reset status OpenAPI * fix(api): address reset polling review feedback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' ])
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user