diff --git a/frontend/src/__tests__/recommendations-enabled-providers.test.ts b/frontend/src/__tests__/recommendations-enabled-providers.test.ts
index c232c109f..cd1680f88 100644
--- a/frontend/src/__tests__/recommendations-enabled-providers.test.ts
+++ b/frontend/src/__tests__/recommendations-enabled-providers.test.ts
@@ -15,6 +15,7 @@ jest.mock('../api', () => ({
getRecommendations: jest.fn(),
refreshRecommendations: jest.fn(),
listAccounts: jest.fn().mockResolvedValue([]),
+ listAccountsMinimal: jest.fn().mockResolvedValue([]),
getConfig: jest.fn(),
listAccountServiceOverrides: jest.fn().mockResolvedValue([]),
}));
diff --git a/frontend/src/__tests__/recommendations-lookback.test.ts b/frontend/src/__tests__/recommendations-lookback.test.ts
index 3d05e71d6..a0090aab5 100644
--- a/frontend/src/__tests__/recommendations-lookback.test.ts
+++ b/frontend/src/__tests__/recommendations-lookback.test.ts
@@ -28,6 +28,7 @@ jest.mock('../api', () => ({
getConfig: jest.fn(),
updateConfig: jest.fn().mockResolvedValue({}),
listAccounts: jest.fn().mockResolvedValue([]),
+ listAccountsMinimal: jest.fn().mockResolvedValue([]),
listAccountServiceOverrides: jest.fn().mockResolvedValue([]),
}));
diff --git a/frontend/src/__tests__/recommendations-permissions.test.ts b/frontend/src/__tests__/recommendations-permissions.test.ts
index 3f1a95636..2a5233f1b 100644
--- a/frontend/src/__tests__/recommendations-permissions.test.ts
+++ b/frontend/src/__tests__/recommendations-permissions.test.ts
@@ -25,6 +25,7 @@ jest.mock('../api', () => ({
refreshRecommendations: jest.fn(),
getConfig: jest.fn().mockResolvedValue({ global: {} }),
listAccounts: jest.fn().mockResolvedValue([]),
+ listAccountsMinimal: jest.fn().mockResolvedValue([]),
listAccountServiceOverrides: jest.fn().mockResolvedValue([]),
}));
diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts
index 3ecae87f1..12d451084 100644
--- a/frontend/src/__tests__/recommendations.test.ts
+++ b/frontend/src/__tests__/recommendations.test.ts
@@ -9,6 +9,7 @@ jest.mock('../api', () => ({
getRecommendations: jest.fn(),
refreshRecommendations: jest.fn(),
listAccounts: jest.fn().mockResolvedValue([]),
+ listAccountsMinimal: jest.fn().mockResolvedValue([]),
// issue #223: getConfig is fetched on page load to resolve GlobalConfig
// defaults (DefaultTerm + DefaultPayment). Default-empty global config so
// pre-#223 tests retain their hardcoded-fallback behavior without extra setup.
@@ -7833,3 +7834,95 @@ describe('Issue #135: SP group parent row rendered in table', () => {
expect(groupSavings!.textContent).toContain('$300');
});
});
+
+// ============================================================================
+// Issue #1419: account display name shown for non-admin users
+// ============================================================================
+// loadRecommendations must call listAccountsMinimal() (which hits
+// /api/accounts/list, accessible to all users with view:recommendations)
+// rather than listAccounts() (which hits /api/accounts, admin-only).
+// Non-admin users previously saw the raw UUID because listAccounts()
+// returned 403, .catch(() => []) silenced it, and the empty cache caused
+// the ID to fall through as the display value.
+describe('Issue #1419: Opportunities Account column shows display name via listAccountsMinimal', () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+ (state.getHiddenColumns as jest.Mock).mockReturnValue(new Set());
+ (state.getRecommendationsSort as jest.Mock).mockReturnValue({ column: 'savings', direction: 'desc' });
+ (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({});
+ (state.getCurrentUser as jest.Mock).mockReturnValue({ id: 'u-viewer', email: 'viewer@example.com', groups: [] });
+ (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({
+ last_collected_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
+ last_collection_error: null,
+ });
+ (recsApi.refreshRecommendations as jest.Mock).mockResolvedValue({});
+ });
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('listAccountsMinimal() is called (not listAccounts) so non-admin users get account names', async () => {
+ // Simulate listAccountsMinimal succeeding for a non-admin user.
+ (api.listAccountsMinimal as jest.Mock).mockResolvedValue([
+ { id: 'acct-uuid-1234', name: 'Production AWS', external_id: '123456789', provider: 'aws' },
+ ]);
+ (api.getRecommendations as jest.Mock).mockResolvedValue({
+ summary: {},
+ recommendations: [{
+ id: 'rec-acct', provider: 'aws', cloud_account_id: 'acct-uuid-1234',
+ service: 'ec2', resource_type: 't3.medium', region: 'us-east-1',
+ count: 1, term: 1, payment: 'all-upfront', savings: 50, upfront_cost: 200,
+ }],
+ regions: [],
+ });
+
+ await loadRecommendations();
+
+ // listAccountsMinimal must have been called (not the admin-only listAccounts).
+ expect(api.listAccountsMinimal).toHaveBeenCalled();
+ expect(api.listAccounts).not.toHaveBeenCalled();
+
+ const list = document.getElementById('recommendations-list');
+ const html = list?.innerHTML ?? '';
+ // The display name must appear in the rendered row.
+ expect(html).toContain('Production AWS');
+ // The raw UUID must NOT appear as the cell value.
+ expect(html).not.toContain('acct-uuid-1234');
+ });
+
+ test('falls back to account ID when listAccountsMinimal returns no matching entry', async () => {
+ (api.listAccountsMinimal as jest.Mock).mockResolvedValue([
+ { id: 'acct-other', name: 'Other Account', external_id: '999', provider: 'aws' },
+ ]);
+ (api.getRecommendations as jest.Mock).mockResolvedValue({
+ summary: {},
+ recommendations: [{
+ id: 'rec-fallback', provider: 'aws', cloud_account_id: 'acct-uuid-no-match',
+ service: 'ec2', resource_type: 't3.medium', region: 'us-east-1',
+ count: 1, term: 1, payment: 'all-upfront', savings: 50, upfront_cost: 200,
+ }],
+ regions: [],
+ });
+
+ await loadRecommendations();
+
+ const list = document.getElementById('recommendations-list');
+ const html = list?.innerHTML ?? '';
+ // No matching entry: falls back to the raw ID.
+ expect(html).toContain('acct-uuid-no-match');
+ });
+});
diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts
index 10c4fd749..b3daf8e20 100644
--- a/frontend/src/recommendations.ts
+++ b/frontend/src/recommendations.ts
@@ -499,7 +499,7 @@ export async function loadRecommendations(): Promise {
const [data, accounts, cfgResponse] = await Promise.all([
api.getRecommendations(filters) as unknown as RecommendationsResponse,
- api.listAccounts().catch(() => []),
+ api.listAccountsMinimal().catch(() => []),
// issue #223: fetch GlobalConfig so per-cell variant selection and
// bulk-toolbar defaults reflect the operator's configured preference.
// Failure is siloed — a missing/unreachable config endpoint must not