feat(api): expose family exports (#1632)

* feat(api): expose family exports

* fix(api): harden family export review paths

* fix(api): tighten family export review paths

* fix(api): reject invalid family export params

* fix(api): address family export review

* fix(api): share uuid guard for exports
This commit is contained in:
ghost
2026-05-03 03:29:29 -06:00
committed by GitHub
parent 6c84fc760e
commit 50936000e7
11 changed files with 803 additions and 6 deletions

View File

@@ -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}"

View File

@@ -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

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true
json.data do
json.partial! "api/v1/family_exports/family_export", family_export: @family_export
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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],

View File

@@ -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