mirror of
https://github.com/we-promise/sure.git
synced 2026-05-07 12:54:04 +00:00
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
This commit is contained in:
24
app/controllers/api/v1/provider_connections_controller.rb
Normal file
24
app/controllers/api/v1/provider_connections_controller.rb
Normal file
@@ -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
|
||||
248
app/models/provider_connection_status.rb
Normal file
248
app/models/provider_connection_status.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
54
spec/requests/api/v1/provider_connections_spec.rb
Normal file
54
spec/requests/api/v1/provider_connections_spec.rb
Normal file
@@ -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
|
||||
@@ -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],
|
||||
|
||||
187
test/controllers/api/v1/provider_connections_controller_test.rb
Normal file
187
test/controllers/api/v1/provider_connections_controller_test.rb
Normal file
@@ -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
|
||||
81
test/models/provider_connection_status_test.rb
Normal file
81
test/models/provider_connection_status_test.rb
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user