From 45c528414892af635305e4457034b563daaceccd Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Wed, 6 May 2026 16:42:32 -0600 Subject: [PATCH] feat(api): expose provider connection health (#1636) * feat(api): expose provider connection health * fix(api): harden provider health review paths * fix(api): refine provider health responses * test(api): align provider health docs key scope * fix(api): clarify provider connection status * fix(api): batch provider connection sync status * fix(api): polish provider connection status review feedback * fix(api): correct provider connection summaries --- .../api/v1/provider_connections_controller.rb | 24 ++ app/models/provider_connection_status.rb | 248 ++++++++++++++++++ .../_provider_connection.json.jbuilder | 17 ++ .../provider_connections/index.json.jbuilder | 5 + config/routes.rb | 1 + docs/api/openapi.yaml | 187 +++++++++++++ .../api/v1/provider_connections_spec.rb | 54 ++++ spec/swagger_helper.rb | 83 ++++++ .../provider_connections_controller_test.rb | 187 +++++++++++++ .../models/provider_connection_status_test.rb | 81 ++++++ 10 files changed, 887 insertions(+) create mode 100644 app/controllers/api/v1/provider_connections_controller.rb create mode 100644 app/models/provider_connection_status.rb create mode 100644 app/views/api/v1/provider_connections/_provider_connection.json.jbuilder create mode 100644 app/views/api/v1/provider_connections/index.json.jbuilder create mode 100644 spec/requests/api/v1/provider_connections_spec.rb create mode 100644 test/controllers/api/v1/provider_connections_controller_test.rb create mode 100644 test/models/provider_connection_status_test.rb diff --git a/app/controllers/api/v1/provider_connections_controller.rb b/app/controllers/api/v1/provider_connections_controller.rb new file mode 100644 index 000000000..11e0c6f7b --- /dev/null +++ b/app/controllers/api/v1/provider_connections_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Api::V1::ProviderConnectionsController < Api::V1::BaseController + before_action :ensure_read_scope + + def index + @provider_connections = ProviderConnectionStatus.for_family(Current.family) + render :index + rescue StandardError => e + Rails.logger.error "ProviderConnectionsController#index error: #{e.message}" + e.backtrace&.each { |line| Rails.logger.error line } + + render_json({ + error: "internal_server_error", + message: "An unexpected error occurred" + }, status: :internal_server_error) + end + + private + + def ensure_read_scope + authorize_scope!(:read) + end +end diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb new file mode 100644 index 000000000..e1a4b4100 --- /dev/null +++ b/app/models/provider_connection_status.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +class ProviderConnectionStatus + PROVIDERS = [ + { key: "plaid", type: "PlaidItem", association: :plaid_items, accounts: :plaid_accounts }, + { key: "simplefin", type: "SimplefinItem", association: :simplefin_items, accounts: :simplefin_accounts }, + { key: "lunchflow", type: "LunchflowItem", association: :lunchflow_items, accounts: :lunchflow_accounts }, + { key: "enable_banking", type: "EnableBankingItem", association: :enable_banking_items, accounts: :enable_banking_accounts }, + { key: "coinbase", type: "CoinbaseItem", association: :coinbase_items, accounts: :coinbase_accounts }, + { key: "binance", type: "BinanceItem", association: :binance_items, accounts: :binance_accounts }, + { key: "coinstats", type: "CoinstatsItem", association: :coinstats_items, accounts: :coinstats_accounts }, + { key: "snaptrade", type: "SnaptradeItem", association: :snaptrade_items, accounts: :snaptrade_accounts, linked_accounts: :linked_accounts }, + { key: "mercury", type: "MercuryItem", association: :mercury_items, accounts: :mercury_accounts }, + { key: "sophtron", type: "SophtronItem", association: :sophtron_items, accounts: :sophtron_accounts }, + { key: "indexa_capital", type: "IndexaCapitalItem", association: :indexa_capital_items, accounts: :indexa_capital_accounts } + ].freeze + + class << self + def for_family(family) + PROVIDERS.flat_map do |provider| + relation = family.public_send(provider[:association]) + items = relation.includes(association_includes_for(relation, provider)).ordered.to_a + sync_contexts = sync_contexts_for(provider[:type], items) + + items.map do |item| + new(provider, item, sync_contexts.fetch(item.id, {})).to_h + end + end + end + + private + + def association_includes_for(relation, provider) + includes = [ { provider[:accounts] => :account_provider } ] + includes << provider[:linked_accounts] if provider[:linked_accounts] + includes << :accounts if relation.klass.reflect_on_association(:accounts) + includes + end + + def sync_contexts_for(provider_type, items) + item_ids = items.map(&:id) + return {} if item_ids.empty? + + latest_syncs = latest_syncs_for(provider_type, item_ids) + latest_completed_syncs = latest_syncs_for(provider_type, item_ids, scope: Sync.completed) + syncing_item_ids = Sync.visible + .where(syncable_type: provider_type, syncable_id: item_ids) + .distinct + .pluck(:syncable_id) + + item_ids.index_with do |item_id| + { + latest_sync: latest_syncs[item_id], + latest_completed_sync: latest_completed_syncs[item_id], + syncing: syncing_item_ids.include?(item_id) + } + end + end + + def latest_syncs_for(provider_type, item_ids, scope: Sync.all) + ranked_syncs = scope.where(syncable_type: provider_type, syncable_id: item_ids) + .select( + "syncs.*, " \ + "ROW_NUMBER() OVER (PARTITION BY syncable_id ORDER BY created_at DESC, id DESC) AS sync_rank" + ) + + Sync.from(ranked_syncs, :syncs).where("sync_rank = 1").index_by(&:syncable_id) + end + end + + def initialize(provider, item, sync_context = {}) + @provider = provider + @item = item + @sync_context = sync_context + end + + def to_h + { + id: item.id, + provider: provider[:key], + provider_type: provider[:type], + name: item_value(:name, provider[:key].humanize), + status: item_value(:status), + requires_update: item_boolean(:requires_update?), + credentials_configured: credentials_configured?, + scheduled_for_deletion: item_boolean(:scheduled_for_deletion?), + pending_account_setup: pending_account_setup?, + institution: institution_payload, + accounts: accounts_payload, + sync: sync_payload, + created_at: item.created_at, + updated_at: item.updated_at + } + end + + private + + attr_reader :provider, :item, :sync_context + + def credentials_configured? + item_boolean(:credentials_configured?) + end + + def pending_account_setup? + item_boolean(:pending_account_setup?) + end + + def institution_payload + { + name: item_value(:institution_display_name, item_value(:name, provider[:key].humanize)), + domain: item_value(:institution_domain), + url: item_value(:institution_url) + } + end + + def accounts_payload + @accounts_payload ||= begin + total = provider_account_count + linked = linked_account_count + + { + total_count: total, + linked_count: linked, + unlinked_count: [ total - linked, 0 ].max + } + end + end + + def provider_account_count + records = provider_account_records + return records.size if records + return item.total_accounts_count if item.respond_to?(:total_accounts_count) + + 0 + end + + def linked_account_count + records = provider_account_records + return records.count { |provider_account| linked_provider_account?(provider_account) } if records + return item.linked_accounts_count if item.respond_to?(:linked_accounts_count) + + if provider[:linked_accounts] && item.respond_to?(provider[:linked_accounts]) + return item.public_send(provider[:linked_accounts]).size + end + + return item.accounts.size if item.respond_to?(:accounts) + + 0 + end + + def provider_account_records + return unless item.respond_to?(provider[:accounts]) + + @provider_account_records ||= item.public_send(provider[:accounts]).to_a + end + + def linked_provider_account?(provider_account) + return false unless provider_account.respond_to?(:account_provider) + + association = provider_account.association(:account_provider) + association.loaded? ? association.target.present? : provider_account.account_provider.present? + end + + def sync_payload + { + syncing: syncing?, + status_summary: sync_status_summary, + last_synced_at: latest_completed_sync&.completed_at, + latest: latest_sync_payload(latest_sync) + } + end + + def sync_status_summary + stats = latest_completed_sync_stats + counts = accounts_payload + total = stats.fetch("total_accounts", counts[:total_count]).to_i + linked = stats.fetch("linked_accounts", counts[:linked_count]).to_i + unlinked = stats.fetch("unlinked_accounts", [ total - linked, 0 ].max).to_i + + if total.zero? + "No accounts found" + elsif unlinked.zero? + "#{linked} #{'account'.pluralize(linked)} synced" + else + "#{linked} synced, #{unlinked} need setup" + end + end + + def syncing? + return sync_context[:syncing] if sync_context.key?(:syncing) + + item_boolean(:syncing?) + end + + def latest_sync + sync_context[:latest_sync] + end + + def latest_completed_sync + sync_context[:latest_completed_sync] + end + + def latest_completed_sync_stats + stats = latest_completed_sync&.sync_stats + return stats.stringify_keys if stats.is_a?(Hash) + return {} unless stats.is_a?(String) + + parsed = JSON.parse(stats) + parsed.is_a?(Hash) ? parsed.stringify_keys : {} + rescue JSON::ParserError + {} + end + + def latest_sync_payload(sync) + return unless sync + + { + id: sync.id, + status: sync.status, + created_at: sync.created_at, + syncing_at: sync.syncing_at, + completed_at: sync.completed_at, + failed_at: sync.failed_at, + error: sync_error_payload(sync) + } + end + + def sync_error_payload(sync) + return unless sync.failed? || sync.stale? + + # Provider health treats stale connections as actionable even when the + # generic sync API suppresses stale-without-error payloads. + { + present: true, + message: sync.stale? ? "Sync became stale before completion" : "Sync failed" + } + end + + def item_boolean(method_name) + item_value(method_name, false) == true + end + + def item_value(method_name, default = nil) + return default unless item.respond_to?(method_name) + + item.public_send(method_name) + end +end diff --git a/app/views/api/v1/provider_connections/_provider_connection.json.jbuilder b/app/views/api/v1/provider_connections/_provider_connection.json.jbuilder new file mode 100644 index 000000000..dc233c6f6 --- /dev/null +++ b/app/views/api/v1/provider_connections/_provider_connection.json.jbuilder @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +json.extract! provider_connection, + :id, + :provider, + :provider_type, + :name, + :status, + :requires_update, + :credentials_configured, + :scheduled_for_deletion, + :pending_account_setup, + :institution, + :accounts, + :sync, + :created_at, + :updated_at diff --git a/app/views/api/v1/provider_connections/index.json.jbuilder b/app/views/api/v1/provider_connections/index.json.jbuilder new file mode 100644 index 000000000..2fe69b2f9 --- /dev/null +++ b/app/views/api/v1/provider_connections/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.data do + json.array! @provider_connections, partial: "api/v1/provider_connections/provider_connection", as: :provider_connection +end diff --git a/config/routes.rb b/config/routes.rb index 9f8cd8973..6bc6e2a2a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -454,6 +454,7 @@ Rails.application.routes.draw do resources :syncs, only: [ :index, :show ] do get :latest, on: :collection end + resources :provider_connections, only: [ :index ] resources :chats, only: [ :index, :show, :create, :update, :destroy ] do resources :messages, only: [ :create ] do diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 52a24d3ba..634032ac1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1755,6 +1755,164 @@ components: properties: data: "$ref": "#/components/schemas/ImportDetail" + ProviderConnectionInstitution: + type: object + required: + - name + properties: + name: + type: string + nullable: true + domain: + type: string + nullable: true + url: + type: string + nullable: true + ProviderConnectionAccounts: + type: object + required: + - total_count + - linked_count + - unlinked_count + properties: + total_count: + type: integer + minimum: 0 + linked_count: + type: integer + minimum: 0 + unlinked_count: + type: integer + minimum: 0 + ProviderConnectionSyncLatest: + type: object + required: + - id + - status + - created_at + properties: + id: + type: string + format: uuid + status: + type: string + created_at: + type: string + format: date-time + syncing_at: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + failed_at: + type: string + format: date-time + nullable: true + error: + type: object + nullable: true + description: Sanitized latest sync error summary. Null when the latest sync + is not failed or stale. + required: + - present + properties: + present: + type: boolean + description: Always true when this object is present. + message: + type: string + nullable: true + description: Stable sanitized error category message; raw provider error + text is never exposed. + ProviderConnectionSync: + type: object + required: + - syncing + properties: + syncing: + type: boolean + status_summary: + type: string + nullable: true + last_synced_at: + type: string + format: date-time + nullable: true + latest: + allOf: + - "$ref": "#/components/schemas/ProviderConnectionSyncLatest" + nullable: true + ProviderConnection: + type: object + required: + - id + - provider + - provider_type + - name + - status + - requires_update + - credentials_configured + - scheduled_for_deletion + - pending_account_setup + - institution + - accounts + - sync + - created_at + - updated_at + properties: + id: + type: string + format: uuid + provider: + type: string + provider_type: + type: string + name: + type: string + status: + type: string + nullable: true + requires_update: + type: boolean + nullable: true + description: False when the provider item does not expose this status. + credentials_configured: + type: boolean + nullable: true + description: False when credential readiness is unknown. + scheduled_for_deletion: + type: boolean + nullable: true + description: False when the provider item does not expose this status. + pending_account_setup: + type: boolean + nullable: true + description: False when account setup state is unknown. + institution: + "$ref": "#/components/schemas/ProviderConnectionInstitution" + accounts: + "$ref": "#/components/schemas/ProviderConnectionAccounts" + sync: + "$ref": "#/components/schemas/ProviderConnectionSync" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ProviderConnectionCollection: + type: object + required: + - data + properties: + data: + type: array + items: + "$ref": "#/components/schemas/ProviderConnection" ImportRowMapping: type: object required: @@ -4448,6 +4606,35 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/provider_connections": + get: + summary: Lists provider connection status summaries + description: List safe provider connection status metadata for the authenticated + user's family without exposing credentials, raw provider payloads, or raw + sync errors. + tags: + - Provider Connections + security: + - apiKeyAuth: [] + responses: + '200': + description: provider connection status summaries listed + content: + application/json: + schema: + "$ref": "#/components/schemas/ProviderConnectionCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/recurring_transactions": get: summary: List recurring transactions diff --git a/spec/requests/api/v1/provider_connections_spec.rb b/spec/requests/api/v1/provider_connections_spec.rb new file mode 100644 index 000000000..ec52f9619 --- /dev/null +++ b/spec/requests/api/v1/provider_connections_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "swagger_helper" + +RSpec.describe "Api::V1::ProviderConnections", 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(:api_key_without_read_scope) do + key = ApiKey.generate_secure_key + ApiKey.new( + user: user, + name: "API Docs Write Key", + key: key, + scopes: %w[write], + source: "web" + ).tap { |api_key| api_key.save!(validate: false) } + end + let(:'X-Api-Key') { api_key.plain_key } + + path "/api/v1/provider_connections" do + get "Lists provider connection status summaries" do + description "List safe provider connection status metadata for the authenticated user's family without exposing credentials, raw provider payloads, or raw sync errors." + tags "Provider Connections" + security [ { apiKeyAuth: [] } ] + produces "application/json" + + response "200", "provider connection status summaries listed" do + schema "$ref" => "#/components/schemas/ProviderConnectionCollection" + run_test! + end + + response "401", "unauthorized" do + let(:'X-Api-Key') { nil } + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + + response "403", "insufficient scope" do + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + schema "$ref" => "#/components/schemas/ErrorResponse" + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 886c0cf24..4df5ff0c3 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -954,6 +954,89 @@ RSpec.configure do |config| data: { '$ref' => '#/components/schemas/ImportDetail' } } }, + ProviderConnectionInstitution: { + type: :object, + required: %w[name], + properties: { + name: { type: :string, nullable: true }, + domain: { type: :string, nullable: true }, + url: { type: :string, nullable: true } + } + }, + ProviderConnectionAccounts: { + type: :object, + required: %w[total_count linked_count unlinked_count], + properties: { + total_count: { type: :integer, minimum: 0 }, + linked_count: { type: :integer, minimum: 0 }, + unlinked_count: { type: :integer, minimum: 0 } + } + }, + ProviderConnectionSyncLatest: { + type: :object, + required: %w[id status created_at], + properties: { + id: { type: :string, format: :uuid }, + status: { type: :string }, + created_at: { type: :string, format: :'date-time' }, + syncing_at: { type: :string, format: :'date-time', nullable: true }, + completed_at: { type: :string, format: :'date-time', nullable: true }, + failed_at: { type: :string, format: :'date-time', nullable: true }, + error: { + type: :object, + nullable: true, + description: "Sanitized latest sync error summary. Null when the latest sync is not failed or stale.", + required: %w[present], + properties: { + present: { type: :boolean, description: "Always true when this object is present." }, + message: { type: :string, nullable: true, description: "Stable sanitized error category message; raw provider error text is never exposed." } + } + } + } + }, + ProviderConnectionSync: { + type: :object, + required: %w[syncing], + properties: { + syncing: { type: :boolean }, + status_summary: { type: :string, nullable: true }, + last_synced_at: { type: :string, format: :'date-time', nullable: true }, + latest: { + allOf: [ { '$ref' => '#/components/schemas/ProviderConnectionSyncLatest' } ], + nullable: true + } + } + }, + ProviderConnection: { + type: :object, + required: %w[id provider provider_type name status requires_update credentials_configured scheduled_for_deletion pending_account_setup institution accounts sync created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + provider: { type: :string }, + provider_type: { type: :string }, + name: { type: :string }, + status: { type: :string, nullable: true }, + requires_update: { type: :boolean, nullable: true, description: "False when the provider item does not expose this status." }, + credentials_configured: { type: :boolean, nullable: true, description: "False when credential readiness is unknown." }, + scheduled_for_deletion: { type: :boolean, nullable: true, description: "False when the provider item does not expose this status." }, + pending_account_setup: { type: :boolean, nullable: true, description: "False when account setup state is unknown." }, + institution: { '$ref' => '#/components/schemas/ProviderConnectionInstitution' }, + accounts: { '$ref' => '#/components/schemas/ProviderConnectionAccounts' }, + sync: { '$ref' => '#/components/schemas/ProviderConnectionSync' }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + ProviderConnectionCollection: { + type: :object, + required: %w[data], + properties: { + data: { + type: :array, + items: { '$ref' => '#/components/schemas/ProviderConnection' } + } + } + }, ImportRowMapping: { type: :object, required: %w[key type value create_when_empty creatable mappable], diff --git a/test/controllers/api/v1/provider_connections_controller_test.rb b/test/controllers/api/v1/provider_connections_controller_test.rb new file mode 100644 index 000000000..815aeb9e7 --- /dev/null +++ b/test/controllers/api/v1/provider_connections_controller_test.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::ProviderConnectionsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = @user.family + @mercury_item = mercury_items(:one) + + @user.api_keys.active.destroy_all + + @api_key = ApiKey.create!( + user: @user, + name: "Test Read Key", + scopes: [ "read" ], + display_key: "test_read_#{SecureRandom.hex(8)}", + source: "web" + ) + + @read_write_key = ApiKey.create!( + user: @user, + name: "Test Read-Write Key", + scopes: [ "read_write" ], + display_key: "test_rw_#{SecureRandom.hex(8)}", + source: "mobile" + ) + + redis = Redis.new + redis.del("api_rate_limit:#{@api_key.id}") + redis.del("api_rate_limit:#{@read_write_key.id}") + end + + test "lists provider connection status for current family" do + failed_sync = @mercury_item.syncs.create!( + status: "failed", + failed_at: Time.current, + error: "secret token failed" + ) + + get api_v1_provider_connections_url, headers: api_headers(@api_key) + assert_response :success + + json_response = JSON.parse(response.body) + mercury_connection = json_response["data"].detect do |connection| + connection["id"] == @mercury_item.id && connection["provider"] == "mercury" + end + + assert_not_nil mercury_connection + assert_equal "mercury", mercury_connection["provider"] + assert_equal "MercuryItem", mercury_connection["provider_type"] + assert_equal @mercury_item.name, mercury_connection["name"] + assert_equal @mercury_item.status, mercury_connection["status"] + assert_includes [ true, false ], mercury_connection["requires_update"] + assert_equal true, mercury_connection["credentials_configured"] + assert_includes [ true, false ], mercury_connection["scheduled_for_deletion"] + assert_includes [ true, false ], mercury_connection["pending_account_setup"] + assert_equal @mercury_item.mercury_accounts.count, mercury_connection["accounts"]["total_count"] + assert_equal failed_sync.id, mercury_connection["sync"]["latest"]["id"] + assert_equal true, mercury_connection["sync"]["latest"]["error"]["present"] + assert_equal "Sync failed", mercury_connection["sync"]["latest"]["error"]["message"] + end + + test "reports failed sync errors as present without exposing raw messages" do + failed_sync = @mercury_item.syncs.create!( + status: "failed", + failed_at: Time.current, + error: nil + ) + + get api_v1_provider_connections_url, headers: api_headers(@api_key) + assert_response :success + + mercury_connection = JSON.parse(response.body)["data"].detect do |connection| + connection["id"] == @mercury_item.id && connection["provider"] == "mercury" + end + + assert_equal failed_sync.id, mercury_connection["sync"]["latest"]["id"] + assert_equal true, mercury_connection["sync"]["latest"]["error"]["present"] + assert_equal "Sync failed", mercury_connection["sync"]["latest"]["error"]["message"] + end + + test "reports stale sync errors as present" do + stale_sync = @mercury_item.syncs.create!( + status: "stale", + syncing_at: 2.days.ago + ) + + get api_v1_provider_connections_url, headers: api_headers(@api_key) + assert_response :success + + mercury_connection = JSON.parse(response.body)["data"].detect do |connection| + connection["id"] == @mercury_item.id && connection["provider"] == "mercury" + end + + assert_equal stale_sync.id, mercury_connection["sync"]["latest"]["id"] + assert_equal true, mercury_connection["sync"]["latest"]["error"]["present"] + assert_equal "Sync became stale before completion", mercury_connection["sync"]["latest"]["error"]["message"] + end + + test "does not expose provider secrets or raw sync errors" do + @mercury_item.syncs.create!( + status: "failed", + failed_at: Time.current, + error: "raw provider token secret" + ) + + get api_v1_provider_connections_url, headers: api_headers(@api_key) + assert_response :success + + refute_includes response.body, @mercury_item.token + refute_includes response.body, "raw provider token secret" + end + + test "fails closed when credential readiness is unknown" do + get api_v1_provider_connections_url, headers: api_headers(@api_key) + assert_response :success + + plaid_connection = JSON.parse(response.body)["data"].detect do |connection| + connection["provider"] == "plaid" + end + + assert_not_nil plaid_connection + assert_includes [ true, false ], plaid_connection["requires_update"] + assert_equal false, plaid_connection["credentials_configured"] + assert_includes [ true, false ], plaid_connection["scheduled_for_deletion"] + assert_includes [ true, false ], plaid_connection["pending_account_setup"] + end + + test "excludes another family's provider connections" do + other_item = snaptrade_items(:pending_registration_item) + + get api_v1_provider_connections_url, headers: api_headers(@api_key) + assert_response :success + + ids = JSON.parse(response.body)["data"].map { |connection| connection["id"] } + assert_not_includes ids, other_item.id + end + + test "read_write key can list provider connection status" do + get api_v1_provider_connections_url, headers: api_headers(@read_write_key) + assert_response :success + end + + test "returns an empty list when no provider connections exist" do + ProviderConnectionStatus.stub(:for_family, []) do + get api_v1_provider_connections_url, headers: api_headers(@api_key) + end + + assert_response :success + assert_equal [], JSON.parse(response.body)["data"] + end + + test "requires authentication" do + get api_v1_provider_connections_url + assert_response :unauthorized + end + + test "rejects api keys without read scope" do + write_only_key = ApiKey.new( + user: @user, + name: "Test Write Key", + scopes: [ "write" ], + display_key: "test_write_#{SecureRandom.hex(8)}", + source: "monitoring" + ).tap { |api_key| api_key.save!(validate: false) } + + get api_v1_provider_connections_url, headers: api_headers(write_only_key) + assert_response :forbidden + end + + test "does not leak internal provider status errors" do + ProviderConnectionStatus.stub(:for_family, ->(_family) { raise StandardError, "secret provider failure" }) do + get api_v1_provider_connections_url, headers: api_headers(@api_key) + end + + assert_response :internal_server_error + assert_equal "internal_server_error", JSON.parse(response.body)["error"] + refute_includes response.body, "secret provider failure" + end + + private + + def api_headers(api_key) + { "X-Api-Key" => api_key.plain_key } + end +end diff --git a/test/models/provider_connection_status_test.rb b/test/models/provider_connection_status_test.rb new file mode 100644 index 000000000..88ef50866 --- /dev/null +++ b/test/models/provider_connection_status_test.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "test_helper" + +class ProviderConnectionStatusTest < ActiveSupport::TestCase + test "provider registry covers syncable family provider item associations" do + expected_registry = Family.reflect_on_all_associations(:has_many).filter_map do |association| + next unless association.name.to_s.end_with?("_items") + next unless association.klass.included_modules.include?(Syncable) + + { association: association.name, type: association.klass.name } + end + + registered_registry = ProviderConnectionStatus::PROVIDERS.map do |provider| + { association: provider[:association], type: provider[:type] } + end + + assert_equal expected_registry.sort_by { |entry| entry[:association].to_s }, + registered_registry.sort_by { |entry| entry[:association].to_s } + end + + test "status summary is computed without calling provider item summary" do + provider = ProviderConnectionStatus::PROVIDERS.find { |entry| entry[:association] == :mercury_items } + item = mercury_items(:one) + completed_sync = item.syncs.create!( + status: "completed", + created_at: 1.hour.ago, + completed_at: 1.hour.ago, + sync_stats: { + total_accounts: 2, + linked_accounts: 1, + unlinked_accounts: 1 + } + ) + failed_sync = item.syncs.create!( + status: "failed", + created_at: Time.current, + failed_at: Time.current, + sync_stats: { + total_accounts: 9, + linked_accounts: 9, + unlinked_accounts: 0 + } + ) + + item.expects(:sync_status_summary).never + + status = ProviderConnectionStatus.new( + provider, + item, + latest_sync: failed_sync, + latest_completed_sync: completed_sync, + syncing: false + ).to_h + + assert_equal "1 synced, 1 need setup", status.dig(:sync, :status_summary) + assert_equal failed_sync.id, status.dig(:sync, :latest, :id) + end + + test "account counts use provider account links instead of linked account fallback" do + provider = ProviderConnectionStatus::PROVIDERS.find { |entry| entry[:association] == :mercury_items } + item = mercury_items(:one) + linked_provider_account = item.mercury_accounts.create!( + account_id: "merc_acc_savings_2", + name: "Mercury Savings", + currency: "USD" + ) + AccountProvider.create!( + account: accounts(:other_asset), + provider: linked_provider_account + ) + + item.association(:mercury_accounts).reset + + status = ProviderConnectionStatus.new(provider, item, syncing: false).to_h + + assert_equal 2, status.dig(:accounts, :total_count) + assert_equal 1, status.dig(:accounts, :linked_count) + assert_equal 1, status.dig(:accounts, :unlinked_count) + end +end