mirror of
https://github.com/we-promise/sure.git
synced 2026-05-07 21:04:12 +00:00
* feat(api): expose complete account export state * fix(api): handle malformed account identifiers * fix(api): tighten account export contracts * fix(api): correct account id OpenAPI format * fix(api): tighten account docs auth contracts * docs(api): document balance sheet auth errors * docs(api): clarify account scope fixture
320 lines
11 KiB
Ruby
320 lines
11 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
class Api::V1::AccountsControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
@user = users(:family_admin) # dylan_family user
|
|
@other_family_user = users(:family_member)
|
|
@other_family_user.update!(family: families(:empty))
|
|
|
|
@user.api_keys.active.destroy_all
|
|
@api_key = ApiKey.create!(
|
|
user: @user,
|
|
name: "Test Read Key",
|
|
scopes: [ "read" ],
|
|
source: "web",
|
|
display_key: "test_read_#{SecureRandom.hex(8)}"
|
|
)
|
|
|
|
@other_family_user.api_keys.active.destroy_all
|
|
@other_family_api_key = ApiKey.create!(
|
|
user: @other_family_user,
|
|
name: "Other Family Read Key",
|
|
scopes: [ "read" ],
|
|
source: "web",
|
|
display_key: "other_family_read_#{SecureRandom.hex(8)}"
|
|
)
|
|
end
|
|
|
|
test "should require authentication" do
|
|
get "/api/v1/accounts"
|
|
assert_response :unauthorized
|
|
|
|
response_body = JSON.parse(response.body)
|
|
assert_equal "unauthorized", response_body["error"]
|
|
end
|
|
|
|
test "should require read_accounts scope" do
|
|
api_key_without_read = ApiKey.new(
|
|
user: @user,
|
|
name: "No Read Key",
|
|
scopes: [],
|
|
source: "web",
|
|
display_key: "no_read_#{SecureRandom.hex(8)}"
|
|
)
|
|
# Valid persisted API keys can only be read/read_write; this intentionally
|
|
# bypasses validations to exercise the runtime insufficient-scope guard.
|
|
api_key_without_read.save!(validate: false)
|
|
|
|
get "/api/v1/accounts", params: {}, headers: api_headers(api_key_without_read)
|
|
|
|
assert_response :forbidden
|
|
response_body = JSON.parse(response.body)
|
|
assert_equal "insufficient_scope", response_body["error"]
|
|
ensure
|
|
api_key_without_read&.destroy
|
|
end
|
|
|
|
test "should return user's family accounts successfully" do
|
|
get "/api/v1/accounts", params: {}, headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_body = JSON.parse(response.body)
|
|
|
|
# Should have accounts array
|
|
assert response_body.key?("accounts")
|
|
assert response_body["accounts"].is_a?(Array)
|
|
|
|
# Should have pagination metadata
|
|
assert response_body.key?("pagination")
|
|
assert response_body["pagination"].key?("page")
|
|
assert response_body["pagination"].key?("per_page")
|
|
assert response_body["pagination"].key?("total_count")
|
|
assert response_body["pagination"].key?("total_pages")
|
|
|
|
# All accounts should belong to user's family
|
|
response_body["accounts"].each do |account|
|
|
# We'll validate this by checking the user's family has these accounts
|
|
family_account_names = @user.family.accounts.pluck(:name)
|
|
assert_includes family_account_names, account["name"]
|
|
end
|
|
end
|
|
|
|
test "should only return active accounts" do
|
|
# Make one account inactive
|
|
inactive_account = accounts(:depository)
|
|
inactive_account.disable!
|
|
|
|
get "/api/v1/accounts", params: {}, headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_body = JSON.parse(response.body)
|
|
|
|
# Should not include the inactive account
|
|
account_names = response_body["accounts"].map { |a| a["name"] }
|
|
assert_not_includes account_names, inactive_account.name
|
|
end
|
|
|
|
test "should include disabled accounts when requested" do
|
|
inactive_account = accounts(:depository)
|
|
inactive_account.disable!
|
|
|
|
get "/api/v1/accounts", params: { include_disabled: true }, headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_body = JSON.parse(response.body)
|
|
|
|
account = response_body["accounts"].find { |account_data| account_data["id"] == inactive_account.id }
|
|
assert_not_nil account
|
|
assert_equal "disabled", account["status"]
|
|
end
|
|
|
|
test "should show active account" do
|
|
account = accounts(:depository)
|
|
|
|
get "/api/v1/accounts/#{account.id}", headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_body = JSON.parse(response.body)
|
|
assert_equal account.id, response_body["id"]
|
|
assert_equal account.status, response_body["status"]
|
|
assert_equal account.balance_money.format, response_body["balance"]
|
|
assert_equal money_cents(account.balance_money), response_body["balance_cents"]
|
|
assert_equal account.cash_balance_money.format, response_body["cash_balance"]
|
|
assert_equal money_cents(account.cash_balance_money), response_body["cash_balance_cents"]
|
|
assert_nullable_equal account.subtype, response_body["subtype"]
|
|
assert response_body.key?("institution_name")
|
|
assert response_body.key?("institution_domain")
|
|
assert_nullable_equal account.institution_name, response_body["institution_name"]
|
|
assert_nullable_equal account.institution_domain, response_body["institution_domain"]
|
|
assert_equal account.created_at.iso8601, response_body["created_at"]
|
|
assert_equal account.updated_at.iso8601, response_body["updated_at"]
|
|
end
|
|
|
|
test "should return 404 for unknown account on show" do
|
|
get "/api/v1/accounts/#{SecureRandom.uuid}", headers: api_headers(@api_key)
|
|
|
|
assert_response :not_found
|
|
response_body = JSON.parse(response.body)
|
|
assert_equal "not_found", response_body["error"]
|
|
end
|
|
|
|
test "should return 404 for malformed account id on show" do
|
|
get "/api/v1/accounts/not-a-uuid", headers: api_headers(@api_key)
|
|
|
|
assert_response :not_found
|
|
response_body = JSON.parse(response.body)
|
|
assert_equal "not_found", response_body["error"]
|
|
assert_equal "Account not found", response_body["message"]
|
|
end
|
|
|
|
test "should require authentication on show" do
|
|
account = accounts(:depository)
|
|
|
|
get "/api/v1/accounts/#{account.id}"
|
|
|
|
assert_response :unauthorized
|
|
response_body = JSON.parse(response.body)
|
|
assert_equal "unauthorized", response_body["error"]
|
|
end
|
|
|
|
test "should require read scope on show" do
|
|
account = accounts(:depository)
|
|
api_key_without_read = ApiKey.new(
|
|
user: @user,
|
|
name: "No Read Show Key",
|
|
scopes: [],
|
|
source: "web",
|
|
display_key: "no_read_show_#{SecureRandom.hex(8)}"
|
|
)
|
|
# Valid persisted API keys can only be read/read_write; this intentionally
|
|
# bypasses validations to exercise the runtime insufficient-scope guard.
|
|
api_key_without_read.save!(validate: false)
|
|
|
|
get "/api/v1/accounts/#{account.id}", headers: api_headers(api_key_without_read)
|
|
|
|
assert_response :forbidden
|
|
response_body = JSON.parse(response.body)
|
|
assert_equal "insufficient_scope", response_body["error"]
|
|
ensure
|
|
api_key_without_read&.destroy
|
|
end
|
|
|
|
test "should hide disabled account by default on show" do
|
|
inactive_account = accounts(:depository)
|
|
inactive_account.disable!
|
|
|
|
get "/api/v1/accounts/#{inactive_account.id}", headers: api_headers(@api_key)
|
|
|
|
assert_response :not_found
|
|
end
|
|
|
|
test "should show disabled account when requested" do
|
|
inactive_account = accounts(:depository)
|
|
inactive_account.disable!
|
|
|
|
get "/api/v1/accounts/#{inactive_account.id}",
|
|
params: { include_disabled: true },
|
|
headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_body = JSON.parse(response.body)
|
|
assert_equal inactive_account.id, response_body["id"]
|
|
assert_equal "disabled", response_body["status"]
|
|
end
|
|
|
|
test "should expose subtype across account types" do
|
|
expected_subtypes = {
|
|
accounts(:depository) => "checking",
|
|
accounts(:credit_card) => "credit_card",
|
|
accounts(:investment) => "brokerage",
|
|
accounts(:loan) => "mortgage",
|
|
accounts(:property) => "single_family_home",
|
|
accounts(:vehicle) => "sedan",
|
|
accounts(:crypto) => "exchange",
|
|
accounts(:other_asset) => "collectible",
|
|
accounts(:other_liability) => "personal_debt"
|
|
}
|
|
|
|
expected_subtypes.each { |account, subtype| account.accountable.update!(subtype: subtype) }
|
|
|
|
expected_subtypes.each do |account, subtype|
|
|
get "/api/v1/accounts/#{account.id}", headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
assert_equal subtype, JSON.parse(response.body)["subtype"]
|
|
end
|
|
end
|
|
|
|
test "should not return other family's accounts" do
|
|
get "/api/v1/accounts", params: {}, headers: api_headers(@other_family_api_key)
|
|
|
|
assert_response :success
|
|
response_body = JSON.parse(response.body)
|
|
|
|
# Should return empty array since other family has no accounts in fixtures
|
|
assert_equal [], response_body["accounts"]
|
|
assert_equal 0, response_body["pagination"]["total_count"]
|
|
end
|
|
|
|
test "should handle pagination parameters" do
|
|
# Test with pagination params
|
|
get "/api/v1/accounts", params: { page: 1, per_page: 2 }, headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_body = JSON.parse(response.body)
|
|
|
|
# Should respect per_page limit
|
|
assert response_body["accounts"].length <= 2
|
|
assert_equal 1, response_body["pagination"]["page"]
|
|
assert_equal 2, response_body["pagination"]["per_page"]
|
|
end
|
|
|
|
test "should return proper account data structure" do
|
|
get "/api/v1/accounts", params: {}, headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_body = JSON.parse(response.body)
|
|
|
|
# Should have at least one account from fixtures
|
|
assert response_body["accounts"].length > 0
|
|
|
|
account = response_body["accounts"].first
|
|
|
|
# Check required fields are present
|
|
required_fields = %w[id name balance balance_cents cash_balance cash_balance_cents currency classification account_type]
|
|
required_fields.each do |field|
|
|
assert account.key?(field), "Account should have #{field} field"
|
|
end
|
|
|
|
# Check data types
|
|
assert account["id"].is_a?(String), "ID should be string (UUID)"
|
|
assert account["name"].is_a?(String), "Name should be string"
|
|
assert account["balance"].is_a?(String), "Balance should be string (money)"
|
|
assert account["balance_cents"].is_a?(Integer), "Balance cents should be integer"
|
|
assert account["cash_balance_cents"].is_a?(Integer), "Cash balance cents should be integer"
|
|
assert account["currency"].is_a?(String), "Currency should be string"
|
|
assert %w[asset liability].include?(account["classification"]), "Classification should be asset or liability"
|
|
end
|
|
|
|
test "should handle invalid pagination parameters gracefully" do
|
|
# Test with invalid page number
|
|
get "/api/v1/accounts", params: { page: -1, per_page: "invalid" }, headers: api_headers(@api_key)
|
|
|
|
# Should still return success with default pagination
|
|
assert_response :success
|
|
response_body = JSON.parse(response.body)
|
|
|
|
# Should have pagination info (with defaults applied)
|
|
assert response_body.key?("pagination")
|
|
assert response_body["pagination"]["page"] >= 1
|
|
assert response_body["pagination"]["per_page"] > 0
|
|
end
|
|
|
|
test "should sort accounts alphabetically" do
|
|
get "/api/v1/accounts", params: {}, headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_body = JSON.parse(response.body)
|
|
|
|
# Should be sorted alphabetically by name
|
|
account_names = response_body["accounts"].map { |a| a["name"] }
|
|
assert_equal account_names.sort, account_names
|
|
end
|
|
|
|
private
|
|
|
|
def api_headers(api_key)
|
|
{ "X-Api-Key" => api_key.plain_key }
|
|
end
|
|
|
|
def money_cents(money)
|
|
(money.amount * money.currency.minor_unit_conversion).round(0).to_i
|
|
end
|
|
|
|
def assert_nullable_equal(expected, actual)
|
|
expected.nil? ? assert_nil(actual) : assert_equal(expected, actual)
|
|
end
|
|
end
|