feat(api): expose sync status (#1635)

* feat(api): expose sync status

* fix(api): harden sync status review paths

* fix(api): address sync status review

* fix(api): tighten sync status review fixes

* fix(api): address sync status review

* test(api): avoid secret-like sync fixture key

* test(api): reuse sync status fixture key

* fix(api): align sync route helpers

* fix(api): tighten sync status scoping

* fix(api): make sync status schema nullable-compliant
This commit is contained in:
ghost
2026-05-06 14:02:21 -06:00
committed by GitHub
parent 1e0666eca2
commit 9e369831ce
12 changed files with 817 additions and 6 deletions

View File

@@ -0,0 +1,46 @@
# frozen_string_literal: true
class Api::V1::SyncsController < Api::V1::BaseController
include Pagy::Backend
before_action :ensure_read_scope
before_action :set_sync, only: [ :show ]
def index
@per_page = safe_per_page_param
@pagy, @syncs = pagy(
family_syncs_query.preload(:syncable, :children).ordered,
page: safe_page_param,
limit: @per_page
)
render :index
end
def latest
@sync = family_syncs_query.preload(:syncable, :children).ordered.first
return render json: { data: nil } unless @sync
render :show
end
def show
render :show
end
private
def set_sync
raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id])
@sync = family_syncs_query.preload(:syncable, :children).find(params[:id])
end
def ensure_read_scope
authorize_scope!(:read)
end
def family_syncs_query
Sync.for_family(Current.family, resource_owner: Current.user)
end
end

View File

@@ -15,7 +15,7 @@ class Sync < ApplicationRecord
belongs_to :parent, class_name: "Sync", optional: true
has_many :children, class_name: "Sync", foreign_key: :parent_id, dependent: :destroy
scope :ordered, -> { order(created_at: :desc) }
scope :ordered, -> { order(created_at: :desc, id: :desc) }
scope :incomplete, -> { where("syncs.status IN (?)", %w[pending syncing]) }
scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago) }
@@ -57,6 +57,52 @@ class Sync < ApplicationRecord
def clean
incomplete.where("syncs.created_at < ?", STALE_AFTER.ago).find_each(&:mark_stale!)
end
def for_family(family, resource_owner: nil)
query = where(syncable_type: "Family", syncable_id: family.id)
query = query.or(where(syncable_type: "Account", syncable_id: account_syncable_ids(family, resource_owner)))
family_syncable_associations.each do |association|
query = query.or(
where(syncable_type: association.klass.name, syncable_id: family.public_send(association.name).select(:id))
)
end
query
end
private
def account_syncable_ids(family, resource_owner)
(resource_owner ? resource_owner.accessible_accounts : family.accounts)
.where(family_id: family.id)
.select(:id)
end
def family_syncable_associations
Family.reflect_on_all_associations(:has_many).select do |association|
association.name.to_s.end_with?("_items") &&
association.klass.included_modules.include?(Syncable)
rescue NameError
false
end
end
end
def in_progress?
pending? || syncing?
end
def terminal?
completed? || failed? || stale?
end
def api_error_payload
return unless failed? || stale?
return if stale? && error.blank?
{
message: stale? ? "Sync became stale before completion" : "Sync failed"
}
end
def perform

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
syncable = sync.syncable
json.id sync.id
json.status sync.status
json.in_progress sync.in_progress?
json.terminal sync.terminal?
json.syncable do
json.type sync.syncable_type
json.id sync.syncable_id
json.name syncable&.try(:name)
end
json.parent_id sync.parent_id
json.children_count sync.children.size
json.window_start_date sync.window_start_date
json.window_end_date sync.window_end_date
json.pending_at sync.pending_at
json.syncing_at sync.syncing_at
json.completed_at sync.completed_at
json.failed_at sync.failed_at
json.error sync.api_error_payload
json.created_at sync.created_at
json.updated_at sync.updated_at

View File

@@ -0,0 +1,12 @@
# frozen_string_literal: true
json.data do
json.array! @syncs, partial: "api/v1/syncs/sync", as: :sync
end
json.meta do
json.page @pagy.page
json.per_page @per_page
json.total_count @pagy.count
json.total_pages @pagy.pages
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
json.data do
if @sync
json.partial! "api/v1/syncs/sync", sync: @sync
else
json.nil!
end
end

View File

@@ -450,7 +450,10 @@ Rails.application.routes.draw do
resource :usage, only: [ :show ], controller: :usage
resource :balance_sheet, only: [ :show ], controller: :balance_sheet
resource :family_settings, only: [ :show ], controller: :family_settings
post :sync, to: "sync#create"
post :sync, to: "sync#create", as: :sync_job
resources :syncs, only: [ :index, :show ] do
get :latest, on: :collection
end
resources :chats, only: [ :index, :show, :create, :update, :destroy ] do
resources :messages, only: [ :create ] do

View File

@@ -1887,6 +1887,119 @@ components:
per_page:
type: integer
minimum: 1
SyncableSummary:
type: object
required:
- type
- id
properties:
type:
type: string
id:
type: string
format: uuid
name:
type: string
nullable: true
SyncErrorSummary:
type: object
required:
- message
properties:
message:
type: string
SyncResource:
type: object
required:
- id
- status
- in_progress
- terminal
- syncable
- children_count
- created_at
- updated_at
properties:
id:
type: string
format: uuid
status:
type: string
enum:
- pending
- syncing
- completed
- failed
- stale
in_progress:
type: boolean
terminal:
type: boolean
syncable:
"$ref": "#/components/schemas/SyncableSummary"
parent_id:
type: string
format: uuid
nullable: true
children_count:
type: integer
minimum: 0
window_start_date:
type: string
format: date
nullable: true
window_end_date:
type: string
format: date
nullable: true
pending_at:
type: string
format: date-time
nullable: true
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:
nullable: true
allOf:
- "$ref": "#/components/schemas/SyncErrorSummary"
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
SyncResponse:
type: object
required:
- data
properties:
data:
nullable: true
allOf:
- "$ref": "#/components/schemas/SyncResource"
SyncCollection:
type: object
required:
- data
- meta
properties:
data:
type: array
maxItems: 100
items:
"$ref": "#/components/schemas/SyncResource"
meta:
"$ref": "#/components/schemas/Pagination"
Trade:
type: object
required:
@@ -5004,6 +5117,115 @@ paths:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/syncs":
get:
summary: Lists sync history
description: List sanitized sync status history for the authenticated user's
family, accounts, and provider connections.
tags:
- Syncs
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: syncs listed
content:
application/json:
schema:
"$ref": "#/components/schemas/SyncCollection"
'401':
description: unauthorized
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'403':
description: forbidden
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/syncs/latest":
get:
summary: Shows the latest sync
description: 'Return the most recently created sanitized sync status for the
authenticated user''s family, or data: null when no sync exists.'
tags:
- Syncs
security:
- apiKeyAuth: []
responses:
'200':
description: latest sync shown
content:
application/json:
schema:
"$ref": "#/components/schemas/SyncResponse"
'401':
description: unauthorized
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'403':
description: forbidden
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/syncs/{id}":
parameters:
- name: id
in: path
format: uuid
required: true
schema:
type: string
get:
summary: Shows a sync
description: Return sanitized status metadata for a single family-scoped sync.
tags:
- Syncs
security:
- apiKeyAuth: []
responses:
'200':
description: sync shown
content:
application/json:
schema:
"$ref": "#/components/schemas/SyncResponse"
'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/tags":
get:
summary: List tags

View File

@@ -0,0 +1,141 @@
# frozen_string_literal: true
require "swagger_helper"
RSpec.describe "Api::V1::Syncs", type: :request do
let(:family) do
Family.create!(
name: "API Family",
currency: "USD",
locale: "en",
date_format: "%m-%d-%Y"
)
end
let(:user) do
family.users.create!(
email: "sync-api-user@example.com",
password: "password123",
password_confirmation: "password123"
)
end
let(:api_key) do
key = ApiKey.generate_secure_key
ApiKey.create!(
user: user,
name: "API Docs Key",
key: key,
display_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: "No Read Docs Key",
key: key,
display_key: key,
scopes: %w[write],
source: "web"
).tap { |api_key| api_key.save!(validate: false) }
end
let(:'X-Api-Key') { api_key.plain_key }
let(:sync) { Sync.create!(syncable: family, status: "completed", completed_at: 1.minute.ago) }
let(:id) { sync.id }
path "/api/v1/syncs" do
get "Lists sync history" do
description "List sanitized sync status history for the authenticated user's family, accounts, and provider connections."
tags "Syncs"
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", "syncs listed" do
schema "$ref" => "#/components/schemas/SyncCollection"
before { sync }
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(:'X-Api-Key') { api_key_without_read_scope.plain_key }
schema "$ref" => "#/components/schemas/ErrorResponse"
run_test!
end
end
end
path "/api/v1/syncs/latest" do
get "Shows the latest sync" do
description "Return the most recently created sanitized sync status for the authenticated user's family, or data: null when no sync exists."
tags "Syncs"
security [ { apiKeyAuth: [] } ]
produces "application/json"
response "200", "latest sync shown" do
schema "$ref" => "#/components/schemas/SyncResponse"
before { sync }
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(:'X-Api-Key') { api_key_without_read_scope.plain_key }
schema "$ref" => "#/components/schemas/ErrorResponse"
run_test!
end
end
end
path "/api/v1/syncs/{id}" do
parameter name: :id, in: :path, type: :string, format: :uuid, required: true
get "Shows a sync" do
description "Return sanitized status metadata for a single family-scoped sync."
tags "Syncs"
security [ { apiKeyAuth: [] } ]
produces "application/json"
response "200", "sync shown" do
schema "$ref" => "#/components/schemas/SyncResponse"
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(:'X-Api-Key') { api_key_without_read_scope.plain_key }
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
end

View File

@@ -1027,6 +1027,63 @@ RSpec.configure do |config|
}
}
},
SyncableSummary: {
type: :object,
required: %w[type id],
properties: {
type: { type: :string },
id: { type: :string, format: :uuid },
name: { type: :string, nullable: true }
}
},
SyncErrorSummary: {
type: :object,
required: %w[message],
properties: {
message: { type: :string }
}
},
SyncResource: {
type: :object,
required: %w[id status in_progress terminal syncable children_count created_at updated_at],
properties: {
id: { type: :string, format: :uuid },
status: { type: :string, enum: %w[pending syncing completed failed stale] },
in_progress: { type: :boolean },
terminal: { type: :boolean },
syncable: { '$ref' => '#/components/schemas/SyncableSummary' },
parent_id: { type: :string, format: :uuid, nullable: true },
children_count: { type: :integer, minimum: 0 },
window_start_date: { type: :string, format: :date, nullable: true },
window_end_date: { type: :string, format: :date, nullable: true },
pending_at: { type: :string, format: :'date-time', nullable: true },
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: { nullable: true, allOf: [ { '$ref' => '#/components/schemas/SyncErrorSummary' } ] },
created_at: { type: :string, format: :'date-time' },
updated_at: { type: :string, format: :'date-time' }
}
},
SyncResponse: {
type: :object,
required: %w[data],
properties: {
data: { nullable: true, allOf: [ { '$ref' => '#/components/schemas/SyncResource' } ] }
}
},
SyncCollection: {
type: :object,
required: %w[data meta],
properties: {
data: {
type: :array,
maxItems: 100,
items: { '$ref' => '#/components/schemas/SyncResource' }
},
meta: { '$ref' => '#/components/schemas/Pagination' }
}
},
Trade: {
type: :object,
required: %w[id date amount currency name qty price account created_at updated_at],

View File

@@ -33,7 +33,7 @@ class Api::V1::SyncControllerTest < ActionDispatch::IntegrationTest
test "should trigger sync with valid write API key" do
assert_enqueued_with(job: SyncJob) do
post api_v1_sync_url, headers: api_headers(@api_key)
post api_v1_sync_job_url, headers: api_headers(@api_key)
end
assert_response :accepted
@@ -48,7 +48,7 @@ class Api::V1::SyncControllerTest < ActionDispatch::IntegrationTest
end
test "should reject sync with read-only API key" do
post api_v1_sync_url, headers: api_headers(@read_only_api_key)
post api_v1_sync_job_url, headers: api_headers(@read_only_api_key)
assert_response :forbidden
response_data = JSON.parse(response.body)
@@ -56,7 +56,7 @@ class Api::V1::SyncControllerTest < ActionDispatch::IntegrationTest
end
test "should reject sync without API key" do
post api_v1_sync_url
post api_v1_sync_job_url
assert_response :unauthorized
response_data = JSON.parse(response.body)
@@ -64,7 +64,7 @@ class Api::V1::SyncControllerTest < ActionDispatch::IntegrationTest
end
test "should return proper sync details in response" do
post api_v1_sync_url, headers: api_headers(@api_key)
post api_v1_sync_job_url, headers: api_headers(@api_key)
assert_response :accepted
response_data = JSON.parse(response.body)

View File

@@ -0,0 +1,211 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::SyncsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@account = @family.accounts.first
Sync.for_family(@family).destroy_all
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read-Write Key",
scopes: [ "read_write" ],
display_key: "test_rw_#{SecureRandom.hex(8)}",
source: "web"
)
@read_only_api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
display_key: "test_ro_#{SecureRandom.hex(8)}",
source: "mobile"
)
redis = Redis.new
redis.del("api_rate_limit:#{@api_key.id}")
redis.del("api_rate_limit:#{@read_only_api_key.id}")
redis.close
end
test "lists family scoped syncs" do
family_sync = Sync.create!(syncable: @family, status: "completed", completed_at: 1.hour.ago)
account_sync = Sync.create!(syncable: @account, status: "syncing", syncing_at: Time.current)
other_sync = Sync.create!(syncable: families(:empty), status: "completed", completed_at: 1.hour.ago)
get api_v1_syncs_url, headers: api_headers(@read_only_api_key)
assert_response :success
json_response = JSON.parse(response.body)
sync_ids = json_response["data"].map { |sync| sync["id"] }
assert_includes sync_ids, family_sync.id
assert_includes sync_ids, account_sync.id
assert_not_includes sync_ids, other_sync.id
assert_equal 2, json_response["meta"]["total_count"]
end
test "does not list account syncs outside caller account access" do
private_account = @family.accounts.create!(
owner: @user,
name: "Private Sync Account",
balance: 0,
currency: "USD",
accountable: Depository.new
)
inaccessible_sync = Sync.create!(syncable: private_account, status: "completed", completed_at: 1.hour.ago)
@read_only_api_key.update_column(:user_id, users(:family_member).id)
get api_v1_syncs_url, headers: api_headers(@read_only_api_key)
assert_response :success
sync_ids = JSON.parse(response.body)["data"].map { |sync| sync["id"] }
assert_not_includes sync_ids, inaccessible_sync.id
end
test "shows a sync" do
sync = Sync.create!(
syncable: @family,
status: "completed",
completed_at: 1.hour.ago,
window_start_date: Date.current - 7.days,
window_end_date: Date.current
)
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
assert_response :success
data = JSON.parse(response.body)["data"]
assert_equal sync.id, data["id"]
assert_equal "completed", data["status"]
assert_equal false, data["in_progress"]
assert_equal true, data["terminal"]
assert_equal "Family", data["syncable"]["type"]
assert_equal @family.id, data["syncable"]["id"]
assert_nil data["error"]
end
test "returns latest sync" do
Sync.create!(syncable: @family, status: "completed", created_at: 2.hours.ago, completed_at: 2.hours.ago)
latest_sync = Sync.create!(syncable: @account, status: "pending", created_at: 1.minute.ago)
get latest_api_v1_syncs_url, headers: api_headers(@read_only_api_key)
assert_response :success
assert_equal latest_sync.id, JSON.parse(response.body)["data"]["id"]
end
test "latest returns null data when no sync exists" do
get latest_api_v1_syncs_url, headers: api_headers(@read_only_api_key)
assert_response :success
assert_nil JSON.parse(response.body)["data"]
end
test "does not expose raw sync errors" do
sync = Sync.create!(
syncable: @family,
status: "failed",
failed_at: Time.current,
error: "provider token secret leaked"
)
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
assert_response :success
data = JSON.parse(response.body)["data"]
assert data["error"].present?
assert_equal "Sync failed", data["error"]["message"]
refute_includes response.body, "provider token secret leaked"
end
test "reports failed sync errors as present without raw error text" do
sync = Sync.create!(
syncable: @family,
status: "failed",
failed_at: Time.current,
error: nil
)
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
assert_response :success
assert JSON.parse(response.body).dig("data", "error").present?
assert_equal "Sync failed", JSON.parse(response.body).dig("data", "error", "message")
end
test "omits stale sync error payload when no error is present" do
sync = Sync.create!(
syncable: @family,
status: "stale"
)
get api_v1_sync_url(sync), headers: api_headers(@read_only_api_key)
assert_response :success
assert_nil JSON.parse(response.body).dig("data", "error")
end
test "returns not found for another family sync" do
sync = Sync.create!(syncable: families(:empty), status: "completed")
get api_v1_sync_url(sync), 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 sync id" do
get api_v1_sync_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 "index requires authentication" do
get api_v1_syncs_url
assert_response :unauthorized
end
test "latest requires authentication" do
get latest_api_v1_syncs_url
assert_response :unauthorized
end
test "show requires authentication" do
sync = Sync.create!(syncable: @family, status: "completed", completed_at: 1.hour.ago)
get api_v1_sync_url(sync)
assert_response :unauthorized
end
test "index requires read scope" do
api_key_without_read = ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
source: "monitoring",
display_key: "no_read_#{SecureRandom.hex(8)}"
)
api_key_without_read.save!(validate: false)
get api_v1_syncs_url, headers: api_headers(api_key_without_read)
assert_response :forbidden
ensure
api_key_without_read&.destroy
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end

View File

@@ -200,6 +200,46 @@ class SyncTest < ActiveSupport::TestCase
assert_equal "stale", stale_syncing.reload.status
end
test "ordered uses id as deterministic tie breaker" do
timestamp = Time.current.change(usec: 0)
older_id = SecureRandom.uuid
newer_id = SecureRandom.uuid
older_id, newer_id = [ older_id, newer_id ].sort
older_sync = Sync.create!(id: older_id, syncable: accounts(:depository), status: :completed, created_at: timestamp)
newer_sync = Sync.create!(id: newer_id, syncable: accounts(:connected), status: :completed, created_at: timestamp)
ordered_ids = Sync.where(id: [ older_sync.id, newer_sync.id ]).ordered.pluck(:id)
assert_equal [ newer_sync.id, older_sync.id ], ordered_ids
end
test "for_family includes syncable provider item associations from family reflections" do
family = families(:dylan_family)
syncable_item_associations = Family.reflect_on_all_associations(:has_many).select do |association|
association.name.to_s.end_with?("_items") &&
association.klass.included_modules.include?(Syncable)
rescue NameError
false
end
syncs = syncable_item_associations.filter_map do |association|
syncable = family.public_send(association.name).first
next unless syncable
Sync.create!(syncable: syncable, status: :completed)
end
assert syncs.any?, "Expected syncable provider item fixtures for this family"
assert_equal syncs.map(&:id).sort, Sync.for_family(family).where(id: syncs.map(&:id)).pluck(:id).sort
end
test "api error payload is present for failed syncs without raw error text" do
sync = Sync.create!(syncable: accounts(:depository), status: :failed)
assert_equal({ message: "Sync failed" }, sync.api_error_payload)
end
test "expand_window_if_needed widens start and end dates on a pending sync" do
initial_start = 1.day.ago.to_date
initial_end = 1.day.ago.to_date