diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index d768bdf3f..dd2fc993a 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -3,6 +3,9 @@ class Api::V1::BaseController < ApplicationController include Doorkeeper::Rails::Helpers + UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i + private_constant :UUID_PATTERN + # Skip regular session-based authentication for API skip_authentication @@ -209,6 +212,10 @@ class Api::V1::BaseController < ApplicationController render json: data, status: status end + def valid_uuid?(value) + value.to_s.match?(UUID_PATTERN) + end + # Error handlers def handle_not_found(exception) Rails.logger.warn "API Record Not Found: #{exception.message}" diff --git a/app/controllers/api/v1/family_exports_controller.rb b/app/controllers/api/v1/family_exports_controller.rb new file mode 100644 index 000000000..e3ad3fcfb --- /dev/null +++ b/app/controllers/api/v1/family_exports_controller.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +class Api::V1::FamilyExportsController < Api::V1::BaseController + include Pagy::Backend + + before_action :ensure_read_scope, only: [ :index, :show, :download ] + before_action :ensure_write_scope, only: [ :create ] + before_action :ensure_admin + before_action :set_family_export, only: [ :show, :download ] + + def index + family_exports_query = current_resource_owner.family + .family_exports + .with_attached_export_file + .ordered + + @per_page = safe_per_page_param + @pagy, @family_exports = pagy( + family_exports_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue StandardError => e + Rails.logger.error "FamilyExportsController#index error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "An unexpected error occurred" + }, status: :internal_server_error + end + + def show + render :show + end + + def create + if unsupported_create_params? + render json: { + error: "invalid_params", + message: "Family export creation does not accept request parameters" + }, status: :unprocessable_entity + return + end + + @family_export = current_resource_owner.family.family_exports.create! + FamilyDataExportJob.perform_later(@family_export) + + render :show, status: :accepted + rescue StandardError => e + Rails.logger.error "FamilyExportsController#create error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "An unexpected error occurred" + }, status: :internal_server_error + end + + def download + unless @family_export.downloadable? + render json: { + error: "export_not_ready", + message: "Export is not ready for download" + }, status: :conflict + return + end + + redirect_to rails_blob_url(@family_export.export_file, disposition: "attachment"), allow_other_host: true + rescue StandardError => e + Rails.logger.error "FamilyExportsController#download error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "An unexpected error occurred" + }, status: :internal_server_error + end + + private + + def set_family_export + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + @family_export = current_resource_owner.family.family_exports.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def ensure_write_scope + authorize_scope!(:write) + end + + def ensure_admin + return if current_resource_owner.admin? + + render json: { + error: "forbidden", + message: "Family exports require a family admin" + }, status: :forbidden + end + + def unsupported_create_params? + params.except(:controller, :action, :format).present? + end + + def safe_page_param + page = params[:page].to_i + page > 0 ? page : 1 + end + + def safe_per_page_param + per_page = params[:per_page].to_i + + case per_page + when 1..100 + per_page + else + 25 + end + end +end diff --git a/app/controllers/api/v1/recurring_transactions_controller.rb b/app/controllers/api/v1/recurring_transactions_controller.rb index 2d367c880..9e56464c9 100644 --- a/app/controllers/api/v1/recurring_transactions_controller.rb +++ b/app/controllers/api/v1/recurring_transactions_controller.rb @@ -3,8 +3,6 @@ class Api::V1::RecurringTransactionsController < Api::V1::BaseController include Pagy::Backend - UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i - before_action :ensure_read_scope, only: %i[index show] before_action :ensure_write_scope, only: %i[create update destroy] before_action :set_readable_recurring_transaction, only: :show @@ -211,10 +209,6 @@ class Api::V1::RecurringTransactionsController < Api::V1::BaseController raise(ActiveRecord::RecordNotFound, "Merchant not found") end - def valid_uuid?(value) - value.to_s.match?(UUID_REGEX) - end - def validate_create_write_params(recurring_transaction) input = recurring_transaction_input recurring_transaction.errors.add(:last_occurrence_date, :blank) if input[:last_occurrence_date].blank? diff --git a/app/views/api/v1/family_exports/_family_export.json.jbuilder b/app/views/api/v1/family_exports/_family_export.json.jbuilder new file mode 100644 index 000000000..f8f0d304a --- /dev/null +++ b/app/views/api/v1/family_exports/_family_export.json.jbuilder @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +json.id family_export.id +json.status family_export.status +json.filename family_export.filename +json.downloadable family_export.downloadable? +json.download_path family_export.downloadable? ? download_api_v1_family_export_path(family_export) : nil +attached = family_export.export_file.attached? +json.file do + json.attached attached + json.byte_size attached ? family_export.export_file.byte_size : nil + json.content_type attached ? family_export.export_file.content_type : nil +end +json.created_at family_export.created_at +json.updated_at family_export.updated_at diff --git a/app/views/api/v1/family_exports/index.json.jbuilder b/app/views/api/v1/family_exports/index.json.jbuilder new file mode 100644 index 000000000..d3d3b9d22 --- /dev/null +++ b/app/views/api/v1/family_exports/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.data do + json.array! @family_exports, partial: "api/v1/family_exports/family_export", as: :family_export +end + +json.meta do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/family_exports/show.json.jbuilder b/app/views/api/v1/family_exports/show.json.jbuilder new file mode 100644 index 000000000..b9c2ecd12 --- /dev/null +++ b/app/views/api/v1/family_exports/show.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.data do + json.partial! "api/v1/family_exports/family_export", family_export: @family_export +end diff --git a/config/routes.rb b/config/routes.rb index d20e66719..2cb17b5c0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -430,6 +430,9 @@ Rails.application.routes.draw do resources :holdings, only: [ :index, :show ] resources :valuations, only: [ :index, :create, :update, :show ] resources :recurring_transactions, only: [ :index, :show, :create, :update, :destroy ] + resources :family_exports, only: [ :index, :show, :create ] do + get :download, on: :member + end resources :imports, only: [ :index, :show, :create ] resource :usage, only: [ :show ], controller: :usage resource :balance_sheet, only: [ :show ], controller: :balance_sheet diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index da0204a01..7533b638c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -37,6 +37,76 @@ components: total_pages: type: integer minimum: 0 + FamilyExportFile: + type: object + required: + - attached + properties: + attached: + type: boolean + byte_size: + type: integer + nullable: true + minimum: 0 + content_type: + type: string + nullable: true + FamilyExport: + type: object + required: + - id + - status + - filename + - downloadable + - file + - created_at + - updated_at + properties: + id: + type: string + format: uuid + status: + type: string + enum: + - pending + - processing + - completed + - failed + filename: + type: string + downloadable: + type: boolean + download_path: + type: string + nullable: true + file: + "$ref": "#/components/schemas/FamilyExportFile" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + FamilyExportResponse: + type: object + required: + - data + properties: + data: + "$ref": "#/components/schemas/FamilyExport" + FamilyExportCollection: + type: object + required: + - data + - meta + properties: + data: + type: array + maxItems: 100 + items: + "$ref": "#/components/schemas/FamilyExport" + meta: + "$ref": "#/components/schemas/Pagination" ErrorResponse: type: object required: @@ -2260,6 +2330,164 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/family_exports": + get: + summary: Lists family exports + tags: + - Family Exports + security: + - apiKeyAuth: [] + parameters: + - name: page + in: query + required: false + description: 'Page number (default: 1)' + schema: + type: integer + - name: per_page + in: query + required: false + description: 'Items per page (default: 25, max: 100)' + schema: + type: integer + responses: + '200': + description: family exports listed + content: + application/json: + schema: + "$ref": "#/components/schemas/FamilyExportCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: forbidden + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + post: + summary: Queues a family export + tags: + - Family Exports + security: + - apiKeyAuth: [] + parameters: [] + responses: + '202': + description: family export queued + content: + application/json: + schema: + "$ref": "#/components/schemas/FamilyExportResponse" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: forbidden + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: invalid params + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: false + description: Family export creation does not accept request parameters. + "/api/v1/family_exports/{id}": + parameters: + - name: id + in: path + format: uuid + required: true + schema: + type: string + get: + summary: Shows a family export + tags: + - Family Exports + security: + - apiKeyAuth: [] + responses: + '200': + description: family export shown + content: + application/json: + schema: + "$ref": "#/components/schemas/FamilyExportResponse" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: forbidden + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/family_exports/{id}/download": + parameters: + - name: id + in: path + format: uuid + required: true + schema: + type: string + get: + summary: Downloads a completed family export + tags: + - Family Exports + security: + - apiKeyAuth: [] + responses: + '302': + description: family export download redirected + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: forbidden + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '409': + description: export not ready + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/holdings": get: summary: List holdings diff --git a/spec/requests/api/v1/family_exports_spec.rb b/spec/requests/api/v1/family_exports_spec.rb new file mode 100644 index 000000000..df3b140c7 --- /dev/null +++ b/spec/requests/api/v1/family_exports_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require "swagger_helper" + +RSpec.describe "Api::V1::FamilyExports", type: :request do + let(:user) { users(:family_admin) } + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: "API Docs Key", + key: key, + scopes: %w[read_write], + source: "web" + ) + end + let(:'X-Api-Key') { api_key.plain_key } + let(:family_export) { user.family.family_exports.create!(status: "completed") } + let(:id) { family_export.id } + + path "/api/v1/family_exports" do + get "Lists family exports" do + tags "Family Exports" + security [ apiKeyAuth: [] ] + produces "application/json" + parameter name: :page, in: :query, type: :integer, required: false, description: "Page number (default: 1)" + parameter name: :per_page, in: :query, type: :integer, required: false, description: "Items per page (default: 25, max: 100)" + + response "200", "family exports listed" do + schema "$ref" => "#/components/schemas/FamilyExportCollection" + run_test! + end + + response "401", "unauthorized" do + let(:'X-Api-Key') { nil } + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + + response "403", "forbidden" do + let(:user) { users(:family_member) } + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + end + + post "Queues a family export" do + tags "Family Exports" + security [ apiKeyAuth: [] ] + consumes "application/json" + produces "application/json" + parameter name: :body, in: :body, required: false, schema: { + type: :object, + additionalProperties: false, + description: "Family export creation does not accept request parameters." + } + + let(:body) { {} } + + response "202", "family export queued" do + schema "$ref" => "#/components/schemas/FamilyExportResponse" + run_test! + end + + response "401", "unauthorized" do + let(:'X-Api-Key') { nil } + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + + response "403", "forbidden" do + let(:user) { users(:family_member) } + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + + response "422", "invalid params" do + let(:body) { { family_export: { status: "completed" } } } + + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + end + end + + path "/api/v1/family_exports/{id}" do + parameter name: :id, in: :path, type: :string, format: :uuid, required: true + + get "Shows a family export" do + tags "Family Exports" + security [ apiKeyAuth: [] ] + produces "application/json" + + response "200", "family export shown" do + schema "$ref" => "#/components/schemas/FamilyExportResponse" + run_test! + end + + response "401", "unauthorized" do + let(:'X-Api-Key') { nil } + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + + response "403", "forbidden" do + let(:user) { users(:family_member) } + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + + response "404", "not found" do + let(:id) { SecureRandom.uuid } + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + end + end + + path "/api/v1/family_exports/{id}/download" do + parameter name: :id, in: :path, type: :string, format: :uuid, required: true + + get "Downloads a completed family export" do + tags "Family Exports" + security [ apiKeyAuth: [] ] + produces "application/json" + + response "302", "family export download redirected" do + before do + family_export.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + end + + run_test! + end + + response "401", "unauthorized" do + let(:'X-Api-Key') { nil } + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + + response "403", "forbidden" do + let(:user) { users(:family_member) } + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + + response "404", "not found" do + let(:id) { SecureRandom.uuid } + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + + response "409", "export not ready" do + let(:family_export) { user.family.family_exports.create!(status: "processing") } + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index ef49b39d6..aaeedd32b 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -43,6 +43,48 @@ RSpec.configure do |config| total_pages: { type: :integer, minimum: 0 } } }, + FamilyExportFile: { + type: :object, + required: %w[attached], + properties: { + attached: { type: :boolean }, + byte_size: { type: :integer, nullable: true, minimum: 0 }, + content_type: { type: :string, nullable: true } + } + }, + FamilyExport: { + type: :object, + required: %w[id status filename downloadable file created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + status: { type: :string, enum: %w[pending processing completed failed] }, + filename: { type: :string }, + downloadable: { type: :boolean }, + download_path: { type: :string, nullable: true }, + file: { '$ref' => '#/components/schemas/FamilyExportFile' }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + FamilyExportResponse: { + type: :object, + required: %w[data], + properties: { + data: { '$ref' => '#/components/schemas/FamilyExport' } + } + }, + FamilyExportCollection: { + type: :object, + required: %w[data meta], + properties: { + data: { + type: :array, + maxItems: 100, + items: { '$ref' => '#/components/schemas/FamilyExport' } + }, + meta: { '$ref' => '#/components/schemas/Pagination' } + } + }, ErrorResponse: { type: :object, required: %w[error], diff --git a/test/controllers/api/v1/family_exports_controller_test.rb b/test/controllers/api/v1/family_exports_controller_test.rb new file mode 100644 index 000000000..ec16da07c --- /dev/null +++ b/test/controllers/api/v1/family_exports_controller_test.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::FamilyExportsControllerTest < ActionDispatch::IntegrationTest + setup do + @admin = users(:family_admin) + @member = users(:family_member) + @family = @admin.family + + @admin.api_keys.active.destroy_all + @member.api_keys.active.destroy_all + + @api_key = ApiKey.create!( + user: @admin, + name: "Test Read-Write Key", + scopes: [ "read_write" ], + display_key: "test_rw_#{SecureRandom.hex(8)}", + source: "web" + ) + + @read_only_api_key = ApiKey.create!( + user: @admin, + name: "Test Read Key", + scopes: [ "read" ], + display_key: "test_ro_#{SecureRandom.hex(8)}", + source: "mobile" + ) + + @member_api_key = ApiKey.create!( + user: @member, + name: "Member Read-Write Key", + scopes: [ "read_write" ], + display_key: "test_member_#{SecureRandom.hex(8)}", + source: "web" + ) + + redis = Redis.new + redis.del("api_rate_limit:#{@api_key.id}") + redis.del("api_rate_limit:#{@read_only_api_key.id}") + redis.del("api_rate_limit:#{@member_api_key.id}") + redis.close + end + + test "lists family exports" do + completed_export = @family.family_exports.create!(status: "completed") + processing_export = @family.family_exports.create!(status: "processing") + + get api_v1_family_exports_url, headers: api_headers(@read_only_api_key) + assert_response :success + + json_response = JSON.parse(response.body) + export_ids = json_response["data"].map { |export| export["id"] } + + assert_includes export_ids, completed_export.id + assert_includes export_ids, processing_export.id + assert_equal @family.family_exports.count, json_response["meta"]["total_count"] + end + + test "shows a family export" do + export = @family.family_exports.create!(status: "completed") + export.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + get api_v1_family_export_url(export), headers: api_headers(@read_only_api_key) + assert_response :success + + json_response = JSON.parse(response.body) + assert_equal export.id, json_response["data"]["id"] + assert_equal "completed", json_response["data"]["status"] + assert_equal true, json_response["data"]["downloadable"] + assert_equal download_api_v1_family_export_path(export), json_response["data"]["download_path"] + assert_equal true, json_response["data"]["file"]["attached"] + assert_equal "application/zip", json_response["data"]["file"]["content_type"] + end + + test "creates a family export job" do + assert_enqueued_with(job: FamilyDataExportJob) do + assert_difference("@family.family_exports.count") do + post api_v1_family_exports_url, headers: api_headers(@api_key) + end + end + + assert_response :accepted + json_response = JSON.parse(response.body) + export = FamilyExport.find(json_response["data"]["id"]) + + assert_equal "pending", export.status + assert_equal @family.id, export.family_id + end + + test "read-only key cannot create a family export" do + assert_no_difference("@family.family_exports.count") do + post api_v1_family_exports_url, headers: api_headers(@read_only_api_key) + end + + assert_response :forbidden + assert_equal "insufficient_scope", JSON.parse(response.body)["error"] + end + + test "create rejects unsupported params" do + assert_no_difference("@family.family_exports.count") do + post api_v1_family_exports_url, + params: { family_export: { status: "completed" } }, + headers: api_headers(@api_key) + end + + assert_response :unprocessable_entity + assert_equal "invalid_params", JSON.parse(response.body)["error"] + end + + test "non-admin cannot access family exports" do + get api_v1_family_exports_url, headers: api_headers(@member_api_key) + assert_response :forbidden + + assert_equal "forbidden", JSON.parse(response.body)["error"] + end + + test "returns not found for another family's export" do + other_family = families(:empty) + other_export = other_family.family_exports.create!(status: "completed") + + get api_v1_family_export_url(other_export), headers: api_headers(@read_only_api_key) + assert_response :not_found + + assert_equal "record_not_found", JSON.parse(response.body)["error"] + end + + test "returns not found for malformed export id" do + get api_v1_family_export_url("not-a-uuid"), headers: api_headers(@read_only_api_key) + assert_response :not_found + + assert_equal "record_not_found", JSON.parse(response.body)["error"] + end + + test "download returns not found for malformed export id" do + get download_api_v1_family_export_url("not-a-uuid"), headers: api_headers(@read_only_api_key) + assert_response :not_found + + assert_equal "record_not_found", JSON.parse(response.body)["error"] + end + + test "redirects completed export downloads to the attached file" do + export = @family.family_exports.create!(status: "completed") + export.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + get download_api_v1_family_export_url(export), headers: api_headers(@read_only_api_key) + assert_response :redirect + assert_includes response.location, "/rails/active_storage/blobs/redirect/" + assert_includes response.location, "test.zip" + end + + test "download returns conflict when export is not ready" do + export = @family.family_exports.create!(status: "processing") + + get download_api_v1_family_export_url(export), headers: api_headers(@read_only_api_key) + assert_response :conflict + + json_response = JSON.parse(response.body) + assert_equal "export_not_ready", json_response["error"] + end + + test "download handles storage URL failures without leaking details" do + export = @family.family_exports.create!(status: "completed") + export.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + Api::V1::FamilyExportsController.any_instance + .stubs(:rails_blob_url) + .raises(StandardError, "storage down") + + get download_api_v1_family_export_url(export), headers: api_headers(@read_only_api_key) + assert_response :internal_server_error + + json_response = JSON.parse(response.body) + assert_equal "internal_server_error", json_response["error"] + assert_equal "An unexpected error occurred", json_response["message"] + assert_not_includes response.body, "storage down" + end + + test "requires authentication" do + get api_v1_family_exports_url + assert_response :unauthorized + end + + private + + def api_headers(api_key) + { "X-Api-Key" => api_key.plain_key } + end +end