mirror of
https://github.com/we-promise/sure.git
synced 2026-05-11 14:45:01 +00:00
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:
@@ -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}"
|
||||
|
||||
126
app/controllers/api/v1/family_exports_controller.rb
Normal file
126
app/controllers/api/v1/family_exports_controller.rb
Normal 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
|
||||
@@ -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?
|
||||
|
||||
15
app/views/api/v1/family_exports/_family_export.json.jbuilder
Normal file
15
app/views/api/v1/family_exports/_family_export.json.jbuilder
Normal 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
|
||||
12
app/views/api/v1/family_exports/index.json.jbuilder
Normal file
12
app/views/api/v1/family_exports/index.json.jbuilder
Normal 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
|
||||
5
app/views/api/v1/family_exports/show.json.jbuilder
Normal file
5
app/views/api/v1/family_exports/show.json.jbuilder
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
164
spec/requests/api/v1/family_exports_spec.rb
Normal file
164
spec/requests/api/v1/family_exports_spec.rb
Normal 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
|
||||
@@ -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],
|
||||
|
||||
201
test/controllers/api/v1/family_exports_controller_test.rb
Normal file
201
test/controllers/api/v1/family_exports_controller_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user