fix(api): filter purchases by account UUID AND external id (closes #701, #498, #866)#956
Conversation
|
Warning Review limit reached
More reviews will be available in 15 minutes and 4 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (16)
📝 WalkthroughWalkthroughThis PR refactors account filtering across purchase history, analytics, and dashboard endpoints to support dual-column matching: both cloud account UUIDs ( ChangesDual-Column Account Filtering
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
Rate Limit Exceeded
|
…l id (refs #701, #498, #866) purchase_history rows carry two account identifiers, either of which may be the only one populated on a row: account_id (the cloud-provider external number, always populated) and cloud_account_id (the cloud_accounts UUID FK, added in migration 000011 with no backfill, so NULL on every direct-execute / ambient / pre-000011 row). The top-bar Account chip emits the UUID, so the prior single-column filter on cloud_account_id silently dropped every NULL row (the "no data" / "No purchase history yet" symptom). Replace GetPurchaseHistoryFiltered's positional args with a PurchaseHistoryFilter struct that carries both AccountIDs (UUIDs) and ExternalIDs, and change the account predicate to the dual-column form (cloud_account_id = ANY($uuids) OR account_id = ANY($externals)). The caller resolves UUIDs to their (provider, external_id) pairs scoped to the user's accessible accounts before populating ExternalIDs, so cross-provider external-id reuse cannot leak. Both account columns are already indexed (idx_purchase_history_account_timestamp on account_id from migration 000002; idx_purchase_history_cloud_account on cloud_account_id from 000011), so no new migration is needed. A one-time backfill of cloud_account_id on legacy rows is intentionally deferred to a follow-up; resolve-at-query keeps the UUID wire contract unchanged. Adds the keystone regression test TestPGXMock_GetPurchaseHistoryFiltered_ExternalIDOnly: a row with cloud_account_id IS NULL and a populated account_id is returned when filtering by that account's UUID. Updates all StoreInterface mocks to the new signature.
…ternal id (closes #701, closes #498, closes #866) The global Provider/Account filter returned no data for accounts whose purchase_history rows carry only the external account_id (cloud_account_id NULL): the top-bar chip emits the cloud_accounts UUID, and every read path filtered the sparse cloud_account_id column alone, so direct-execute / ambient / legacy rows were silently dropped (#701, #498). Row 452 (#866) was the mirror: a UUID sent to a query filtering the external account_id column also matched nothing. Add a shared resolver (Handler.resolveAccountFilterIDs / resolveSingleAccountFilterIDs in scoping.go) that loads cloud_accounts once (reusing ListCloudAccounts) and maps requested UUIDs to their (uuid, external_id) pairs, scoped to known accounts so an external number is never compared against the wrong column. Route every account filter through the dual-column GetPurchaseHistoryFiltered: - /api/history (getHistory + fetchPurchaseHistory): resolve account_ids and the legacy singular account_id into one unified AccountIDs/ExternalIDs set; matchesExecution now also compares the resolved external ids so external-id- only pending executions aren't dropped from the approval queue. - /history/analytics + /history/breakdown: QueryHistory/QueryBreakdown take (accountUUIDs, accountExternalIDs); the SQL account clause is the shared accountFilterClause dual-column predicate (no more dead $3='' branch). - /dashboard/summary (resolveDashboardAccountScope + calculateCommitmentMetrics): dual-column commitment-metrics fetch. - /api/inventory/commitments (fetchCommitmentRecords): resolve the singular account_id UUID to dual-column inputs instead of passing it to the single-column GetPurchaseHistory. resolveAccountNamesByID now also keys by external id so external-id rows render their account name. The UUID wire contract is unchanged and a one-time cloud_account_id backfill is intentionally deferred to a follow-up.
…cs/dashboard/inventory (refs #701, #498, #866) Update the api-package mocks and handler tests to the dual-column signatures and add end-to-end keystone regression tests for the "Account B returns nothing" bug class: - handler_history_test.go: TestHandler_getHistory_ExternalIDOnlyAccount asserts /api/history with account_ids=<B-UUID> returns B's external-id-only rows; TestMatchesExecution_ExternalIDOnlyPending asserts a pending execution carrying only B's external id survives the in-memory filter. - handler_dashboard_test.go: external-id-only commitment rows are counted via the dual-column calculateCommitmentMetrics path. - handler_analytics_test.go: TestHandler_getHistoryAnalytics_ExternalIDOnlyAccount asserts a chip UUID resolves to (UUID, external) and both reach QueryHistory. - analytics_postgres_test.go: dual-column QueryHistory/QueryBreakdown SQL shape and array binding. These tests fail before the fix (single-column filter drops the rows) and pass after.
c7f9e75 to
b1d18f3
Compare
|
Rebased onto latest Conflict resolution:
Verification (local): Not re-pinging CodeRabbit this push: the latest CR comment is a rate-limit notice; the CR-watch sweep will re-trigger once the limit clears. |
…ilter funcs matchesExecution (complexity 12) now delegates dual-id account resolution to accountMatchesFilters, pulling the two-level exec.CloudAccountID/rec-fallback lookup and the UUID-vs-external-id set membership out of the parent. GetPurchaseHistoryFiltered (complexity 11) now delegates OR-predicate building to appendAccountPredicate, pulling the dual-column (cloud_account_id/account_id) arg-appending branch out of the parent. Both helpers carry doc comments per the repo pattern; behavior is unchanged. Fix "Scan for AWS secrets" false positive: test fixture "999988887777" (a descending-step placeholder added by #956) added to .gitallowed alongside the existing countdown values.
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/api/analytics_postgres_test.go`:
- Around line 187-188: The test hard-codes a casted predicate
`cloud_account_id::text = ANY(...)` which prevents use of existing UUID indexes;
update the SQL in internal/api/analytics_postgres.go to compare UUID values
directly (use `cloud_account_id = ANY($n::uuid[])` or `cloud_account_id =
ANY($n)` with the binder providing uuid[]), adjust the code that builds the
filtered analytics WHERE clause accordingly, and then change the pgxmock
expectations in internal/api/analytics_postgres_test.go (the mock.ExpectQuery
regex and WithArgs calls around the lines matching `SELECT date_trunc('day',
timestamp)` ) to expect the new predicate (e.g. `cloud_account_id =
ANY\(\$3::uuid\[\]\) OR account_id = ANY\(\$4\)` ) and bind UUID arrays instead
of string arrays so the test verifies UUID-typed comparisons.
In `@internal/api/handler_dashboard.go`:
- Around line 25-26: The dashboard commitment metrics are not being restricted
to the session's allowed_accounts: before calling calculateCommitmentMetrics (or
before it calls GetAllPurchaseHistory), intersect the account lists returned by
resolveDashboardAccountScope (accountUUIDs, accountExternalIDs) with the
session's allowed_accounts so out-of-scope accounts are removed; alternatively,
after calculateCommitmentMetrics returns, filter its purchase-history rows and
the resulting values used for ActiveCommitments, CommittedMonthly,
CurrentCoverage, and YTDSavings using the same allowed-account gate used for
recommendations to ensure no inaccessible accounts are included. Target
resolveDashboardAccountScope, calculateCommitmentMetrics, GetAllPurchaseHistory
and the variables ActiveCommitments/CommittedMonthly/CurrentCoverage/YTDSavings
when applying the intersection/filter.
In `@internal/config/types.go`:
- Around line 482-488: The ExternalIDs []string field loses the (provider,
external_id) pairing and must be replaced with a provider-coupled shape so SQL
can distinguish duplicates across providers; change the type definition
(ExternalIDs) to carry provider context (e.g., map[string][]string or a slice of
a small struct like ResolvedAccount{Provider string; ExternalID string}) and
update any code that populates or consumes ExternalIDs (the callers that resolve
AccountIDs and the SQL builder that currently uses ExternalIDs to produce
account_id = ANY(...)) so they populate and use the provider-aware structure
instead of the bare []string.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9ffb56af-4aa2-4244-a1dc-3094a22149b3
📒 Files selected for processing (25)
.gitallowedinternal/analytics/collector_test.gointernal/api/analytics_postgres.gointernal/api/analytics_postgres_test.gointernal/api/handler_analytics.gointernal/api/handler_analytics_test.gointernal/api/handler_coverage_test.gointernal/api/handler_dashboard.gointernal/api/handler_dashboard_test.gointernal/api/handler_history.gointernal/api/handler_history_test.gointernal/api/handler_inventory.gointernal/api/handler_inventory_test.gointernal/api/handler_per_account_perms_test.gointernal/api/mocks_test.gointernal/api/scoping.gointernal/api/types.gointernal/config/interfaces.gointernal/config/store_postgres.gointernal/config/store_postgres_pgxmock_test.gointernal/config/types.gointernal/mocks/stores.gointernal/purchase/mocks_test.gointernal/scheduler/scheduler_test.gointernal/server/test_helpers_test.go
…s; drop ::text cast The partial ExternalIDsByProvider map[string][]string refactor introduced in this branch was build-breaking (11 compilation errors) and beyond the scope of the account dual-column predicate fix. Revert to a flat ExternalIDs []string across all callers (analytics_postgres, scoping, handler_dashboard, handler_history, handler_analytics, store_postgres, types). Also drop the ::text cast from cloud_account_id = ANY($n) so the plain UUID index added in migration 000011 can back the predicate. Follow-up issues: - #959: calculateCommitmentMetrics does not honour allowed_accounts scope - #960: introduce ExternalIDsByProvider map for provider-scoped filtering
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
Addressed all 3 CodeRabbit findings (pushed 1. 2. 3. Verification (local): @coderabbitai review |
|
Kicking off the incremental review now against (ミ^ OwO^ミ) 🧠 Learnings used✅ Action performedReview finished.
|
What
The global Provider/Account filter returned no data for many accounts (QA rows 305/306 #701, 384/431 #498, 452 #866). This is the real fix for the root cause that prior patches missed.
Closes #701, closes #498, closes #866.
Root cause
purchase_historyrows carry two account identifiers, and either one may be the only one populated on a given row:account_id— the cloud-provider external number (e.g. an AWS account number). Always populated.cloud_account_id— the internalcloud_accountsUUID FK. Added in migration000011with no backfill, so it isNULLon every direct-execute / ambient / pre-000011row.The top-bar Account chip emits the UUID (
parseAccountIDshard-validates UUID).cloud_account_id(the sparse UUID column), soWHEREmatched nothing for NULL-cloud_account_idrows → "no data" / "No purchase history yet" (bug(api/history): handler ignores provider/account_ids/start/end query params from Purchase History + Purchases-page Global filters #701, ux(home): Account filter doesn't affect Home page Savings-over-time graph (data identical across accounts) #498).account_idcolumn also matched nothing.The analytics SQL even had a dual-column shape already, but it was bound with a single value (
$3 = '' OR account_id = $3 OR cloud_account_id::text = $3), so theaccount_id = $3branch was dead when$3was a UUID.Why prior fixes failed
They committed to one column. Whichever column they picked, the other representation's rows disappeared. There is no single column that is reliably populated for the chip's UUID across all historical rows.
The fix: resolve-at-query, dual-column match
A shared resolver (
Handler.resolveAccountFilterIDs/resolveSingleAccountFilterIDsinscoping.go) loadscloud_accountsonce (reusing the sameListCloudAccountsused byresolveAccountNamesByID) and maps each requested UUID to its(provider, external_id)pair. External ids are taken only from the user's known accounts (andcloud_accountshasUNIQUE(provider, external_id)), so a reused external number across providers can't leak.GetPurchaseHistoryFilterednow takes aPurchaseHistoryFilterstruct (UUIDs + external ids) and matches both columns:Every account-filtered read path routes through this:
/api/history(incl. the in-memorymatchesExecutionmirror for pending executions),/history/analytics+/history/breakdown,/dashboard/summary, and/api/inventory/commitments.The UUID wire contract is unchanged (frontend untouched). A one-time
cloud_account_idbackfill on legacy rows is intentionally deferred to a follow-up — this PR is the resolve-at-query fix so the filter works today regardless of backfill state.Keystone regression test
TestPGXMock_GetPurchaseHistoryFiltered_ExternalIDOnly(store level): a row withcloud_account_id IS NULL+ a realaccount_idis returned when filtering by that account's UUID. Plus end-to-end keystones for/api/history, dashboard commitment metrics, analytics, andmatchesExecution— each fails before the fix (single-column drops the row) and passes after.Index
Both columns are already indexed (
idx_purchase_history_account_timestamponaccount_idfrom000002;idx_purchase_history_cloud_accountoncloud_account_idfrom000011), so no new migration is required.Verification
go build ./...✓gofmt -l,go vet ./...cleango test ./internal/api/... ./internal/config/... ./internal/scheduler/... ./internal/server/... ./internal/purchase/... ./internal/analytics/...green (2788 tests), incl.-raceon the api package.Closes the sheet rows 305, 306, 384, 431, 452.
Summary by CodeRabbit
Release Notes
Bug Fixes
Refactoring
Tests