From 9e369831ce83f62359ebd632acd2da839c30d959 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Wed, 6 May 2026 14:02:21 -0600 Subject: [PATCH] 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 --- app/controllers/api/v1/syncs_controller.rb | 46 ++++ app/models/sync.rb | 48 +++- app/views/api/v1/syncs/_sync.json.jbuilder | 24 ++ app/views/api/v1/syncs/index.json.jbuilder | 12 + app/views/api/v1/syncs/show.json.jbuilder | 9 + config/routes.rb | 5 +- docs/api/openapi.yaml | 222 ++++++++++++++++++ spec/requests/api/v1/syncs_spec.rb | 141 +++++++++++ spec/swagger_helper.rb | 57 +++++ .../api/v1/sync_controller_test.rb | 8 +- .../api/v1/syncs_controller_test.rb | 211 +++++++++++++++++ test/models/sync_test.rb | 40 ++++ 12 files changed, 817 insertions(+), 6 deletions(-) create mode 100644 app/controllers/api/v1/syncs_controller.rb create mode 100644 app/views/api/v1/syncs/_sync.json.jbuilder create mode 100644 app/views/api/v1/syncs/index.json.jbuilder create mode 100644 app/views/api/v1/syncs/show.json.jbuilder create mode 100644 spec/requests/api/v1/syncs_spec.rb create mode 100644 test/controllers/api/v1/syncs_controller_test.rb diff --git a/app/controllers/api/v1/syncs_controller.rb b/app/controllers/api/v1/syncs_controller.rb new file mode 100644 index 000000000..401362392 --- /dev/null +++ b/app/controllers/api/v1/syncs_controller.rb @@ -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 diff --git a/app/models/sync.rb b/app/models/sync.rb index d1ba07a26..cc09b9ffd 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -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 diff --git a/app/views/api/v1/syncs/_sync.json.jbuilder b/app/views/api/v1/syncs/_sync.json.jbuilder new file mode 100644 index 000000000..9550aeca1 --- /dev/null +++ b/app/views/api/v1/syncs/_sync.json.jbuilder @@ -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 diff --git a/app/views/api/v1/syncs/index.json.jbuilder b/app/views/api/v1/syncs/index.json.jbuilder new file mode 100644 index 000000000..70496f7ff --- /dev/null +++ b/app/views/api/v1/syncs/index.json.jbuilder @@ -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 diff --git a/app/views/api/v1/syncs/show.json.jbuilder b/app/views/api/v1/syncs/show.json.jbuilder new file mode 100644 index 000000000..621b5638a --- /dev/null +++ b/app/views/api/v1/syncs/show.json.jbuilder @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 5fa7f90c7..847ed6133 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b60bb7936..3ff0ffae3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/spec/requests/api/v1/syncs_spec.rb b/spec/requests/api/v1/syncs_spec.rb new file mode 100644 index 000000000..90c2dccca --- /dev/null +++ b/spec/requests/api/v1/syncs_spec.rb @@ -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 diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index e63af7b90..f3e9b07cf 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -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], diff --git a/test/controllers/api/v1/sync_controller_test.rb b/test/controllers/api/v1/sync_controller_test.rb index 3a920a672..fd6e18856 100644 --- a/test/controllers/api/v1/sync_controller_test.rb +++ b/test/controllers/api/v1/sync_controller_test.rb @@ -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) diff --git a/test/controllers/api/v1/syncs_controller_test.rb b/test/controllers/api/v1/syncs_controller_test.rb new file mode 100644 index 000000000..ff0943fb9 --- /dev/null +++ b/test/controllers/api/v1/syncs_controller_test.rb @@ -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 diff --git a/test/models/sync_test.rb b/test/models/sync_test.rb index d1182fd51..6fa8be3c9 100644 --- a/test/models/sync_test.rb +++ b/test/models/sync_test.rb @@ -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