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
29 changes: 29 additions & 0 deletions frontend/src/__tests__/purchases-deeplink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,35 @@ describe('handlePurchaseDeeplink approve (issue #247)', () => {
expect(window.location.pathname).toBe('/purchases');
});

it.each([
[403, 'forbidden', 'view:purchases'],
[404, 'execution not found', 'not found or is outside your account access'],
])('details fetch failing with %s is permanent: no retry advice, keeps server message', async (status, message, wording) => {
(api.getPurchaseDetails as jest.Mock).mockRejectedValue(Object.assign(new Error(message), { status }));

await expect(handlePurchaseDeeplink()).resolves.toBe(true);

const text = document.querySelector('[role="alert"]')?.textContent ?? '';
expect(text).toContain(wording);
expect(text).toContain(`(${message})`);
expect(text).not.toContain('retry');
});

it.each([
[500, 'internal error'],
[401, 'unauthorized'],
[undefined, 'Failed to fetch'],
])('details fetch failing with %s keeps the retry path', async (status, message) => {
(api.getPurchaseDetails as jest.Mock).mockRejectedValue(Object.assign(new Error(message), { status }));

await expect(handlePurchaseDeeplink()).resolves.toBe(true);

const text = document.querySelector('[role="alert"]')?.textContent ?? '';
expect(text).toContain('Open the approval link again to retry.');
expect(text).toContain(`(${message})`);
expect(text).not.toContain('view:purchases');
});

it('shows the upfront amount and approves when details load', async () => {
(api.getPurchaseDetails as jest.Mock).mockResolvedValue({
execution_id: EXEC_ID, status: 'pending', total_upfront_cost: 1200, estimated_savings: 10,
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/approval-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,21 @@ export async function buildApprovalDetailsBody(executionId: string): Promise<HTM
return renderApprovalDetailsBody(details, accountsById, hostAWSAccountID);
}

/** True for a 403/404 on the details fetch: retrying cannot succeed. */
export function isPermanentDetailsError(err: unknown): boolean {
const status = (err as { status?: number } | null)?.status;
return status === 403 || status === 404;
}

export function approvalDetailsUnavailableMessage(err: unknown): string {
const cause = err instanceof Error ? err.message : String(err);
return `This purchase cannot be approved without showing the amount, and its details could not be loaded (${cause}).`;
const base = `This purchase cannot be approved without showing the amount, and its details could not be loaded (${cause}).`;
const status = (err as { status?: number } | null)?.status;
if (status === 403) {
return `${base} Loading details requires the view:purchases permission and access to the purchase's account.`;
}
if (status === 404) {
return `${base} The purchase was not found or is outside your account access.`;
}
return base;
}
6 changes: 4 additions & 2 deletions frontend/src/purchases-deeplink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import { apiRequest } from './api/client';
import { showToast } from './toast';
import { confirmDialog } from './confirmDialog';
import { approvalDetailsUnavailableMessage, buildApprovalDetailsBody } from './approval-details';
import { approvalDetailsUnavailableMessage, buildApprovalDetailsBody, isPermanentDetailsError } from './approval-details';

type DeeplinkAction = 'approve' | 'cancel';

Expand Down Expand Up @@ -88,7 +88,9 @@ export async function handlePurchaseDeeplink(): Promise<boolean> {
} catch (err) {
console.error('Failed to load purchase details for approval:', err);
showToast({
message: `${approvalDetailsUnavailableMessage(err)} Open the approval link again to retry.`,
message: isPermanentDetailsError(err)
? approvalDetailsUnavailableMessage(err)
: `${approvalDetailsUnavailableMessage(err)} Open the approval link again to retry.`,
kind: 'error',
timeout: null,
});
Expand Down
17 changes: 16 additions & 1 deletion frontend/tests-e2e/history-action-confirmation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ test('approve is not offered when the purchase details cannot be loaded (issue #
await page.route(`**/api/purchases/${ID}`, route => {
detailGets++;
return detailGets === 1
? route.fulfill({ status: 403, json: { error: 'forbidden' } })
? route.fulfill({ status: 500, json: { error: 'internal error' } })
: route.fulfill({ json: DETAILS });
});
await page.goto('/purchases/history');
Expand All @@ -137,6 +137,21 @@ test('approve is not offered when the purchase details cannot be loaded (issue #
await expect.poll(() => fixture.posts.length).toBe(1);
});

for (const [status, wording] of [[403, 'view:purchases'], [404, 'outside your account access']] as const) {
test(`approval link with details ${status} shows permanent wording without retry advice (issue #529)`, async ({ page }) => {
const fixture = await historyFixture(page, 'approve');
await page.route(`**/api/purchases/${ID}`, route => route.fulfill({ status, json: { error: 'denied by server' } }));
await page.goto(`/purchases/approve/${ID}?token=tok`);
const alert = page.getByRole('alert');
await expect(alert).toContainText('cannot be approved without showing the amount');
await expect(alert).toContainText(wording);
await expect(alert).toContainText('denied by server');
await expect(alert).not.toContainText(/try again|retry/i);
await expect(page.locator('.modal-confirm-backdrop')).toHaveCount(0);
expect(fixture.posts).toHaveLength(0);
});
}

test('canceling an independent row does not steal focus from another confirmation', async ({ page }) => {
const fixture = await historyFixture(page, 'approve');
const details = gate();
Expand Down
Loading