Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions internal/api/handler_inventory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,15 @@ func TestHandler_listActiveCommitments_FiltersExpired(t *testing.T) {
}

mockStore.On("GetAllPurchaseHistory", ctx, config.MaxListLimit).Return(purchases, nil)
// Use realistic fixtures: CloudAccount.ID is a UUID; CloudAccount.ExternalID is
// the provider external ID (e.g. AWS account number) that matches
// PurchaseHistoryRecord.AccountID. The name lookup in resolveAccountNamesByID
// must key by ExternalID, not UUID, to find the name — this is the regression
// guard for issue #952.
mockStore.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) {
return []config.CloudAccount{
{ID: "acc-active", Name: "Active Account"},
{ID: "acc-expired", Name: "Expired Account"},
{ID: "11111111-0000-0000-0000-000000000001", ExternalID: "acc-active", Name: "Active Account"},
{ID: "11111111-0000-0000-0000-000000000002", ExternalID: "acc-expired", Name: "Expired Account"},
}, nil
}

Expand All @@ -111,7 +116,7 @@ func TestHandler_listActiveCommitments_FiltersExpired(t *testing.T) {
row := resp.Commitments[0]
assert.Equal(t, "acc-active:p-active", row.ID, "id namespaces account+purchase")
assert.Equal(t, "acc-active", row.AccountID)
assert.Equal(t, "Active Account", row.AccountName, "account name must be joined from ListCloudAccounts")
assert.Equal(t, "Active Account", row.AccountName, "account name must be resolved via ExternalID (issue #952)")
assert.Equal(t, "aws", row.Provider)
assert.Equal(t, "ec2", row.Service)
assert.Equal(t, 2, row.Count)
Expand Down Expand Up @@ -149,7 +154,7 @@ func TestHandler_listActiveCommitments_AccountFilter(t *testing.T) {

mockStore.On("GetPurchaseHistory", ctx, "acc-1", config.MaxListLimit).Return(purchases, nil)
mockStore.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) {
return []config.CloudAccount{{ID: "acc-1", Name: "Account One"}}, nil
return []config.CloudAccount{{ID: "22222222-0000-0000-0000-000000000001", ExternalID: "acc-1", Name: "Account One"}}, nil
}

mockAuth, req := adminInventoryReq(ctx)
Expand Down
16 changes: 12 additions & 4 deletions internal/api/scoping.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,13 @@ func (h *Handler) requireExecutionAccess(ctx context.Context, session *Session,
}

// resolveAccountNamesByID fetches the complete account list once and returns
// a map from account ID → display name. Used by handlers that filter records
// (recommendations, purchases, history) by account ID but need name-based
// matching in auth.MatchesAccount.
// a map from account identifier → display name. The map is keyed by BOTH
// the internal UUID (CloudAccount.ID) and the cloud-provider external ID
// (CloudAccount.ExternalID, e.g. an AWS account number or Azure subscription
// ID). Dual-keying is necessary because different record types use different
// identifiers: recommendation records carry the UUID (cloud_account_id FK)
// while legacy purchase_history rows carry the external ID (account_id
// VARCHAR). Without both keys, the name lookup always misses for one path.
//
// Returns an empty map on error; callers fall through to ID-only matching,
// which is still safe (MatchesAccount falls back to ID comparison).
Expand All @@ -155,9 +159,13 @@ func (h *Handler) resolveAccountNamesByID(ctx context.Context) map[string]string
if err != nil {
return map[string]string{}
}
nameByID := make(map[string]string, len(accounts))
// Allocate 2× capacity since each account contributes two keys.
nameByID := make(map[string]string, len(accounts)*2)
for _, a := range accounts {
nameByID[a.ID] = a.Name
if a.ExternalID != "" {
nameByID[a.ExternalID] = a.Name
}
}
return nameByID
}
Loading