mirror of
https://github.com/we-promise/sure.git
synced 2026-05-07 21:04:12 +00:00
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:
211
test/controllers/api/v1/syncs_controller_test.rb
Normal file
211
test/controllers/api/v1/syncs_controller_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user