Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
22 changes: 18 additions & 4 deletions app/packages/web/e2e/pages/AuthGatePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Page, Locator } from '@playwright/test';
/**
* Page object for the API-token modal rendered by `ApiTokenModal.tsx` when an
* `/api/*` request returns 401. Used by auth-gate specs to assert the modal
* appears and to drive the token-save → reload flow.
* appears and to drive the token-save → inline-retry flow.
*/
export class AuthGatePage {
constructor(public readonly page: Page) {}
Expand All @@ -15,12 +15,26 @@ export class AuthGatePage {

/** API-token text input inside the modal. */
tokenInput(): Locator {
return this.page.getByPlaceholder('API token');
return this.page.getByPlaceholder('Paste API token');
}

/** "Save & reload" submit button — persists the token to localStorage and reloads. */
/**
* Submit button — persists the token to localStorage and triggers
* `retryPendingAfterAuth()`. The label briefly switches to "Verifying…"
* during the retry, so we anchor to the steady-state "Save" name.
*/
submitButton(): Locator {
return this.page.getByRole('button', { name: 'Save & reload' });
return this.page.getByRole('button', { name: 'Save', exact: true });
}

/** Show/hide eye toggle — flips the password input's `type` attribute. */
showHideToggle(): Locator {
return this.page.getByRole('button', { name: /Show token|Hide token/ });
}

/** Inline error paragraph (validation or 401-on-retry) — `null` when none is rendered. */
errorMessage(): Locator {
return this.page.locator('[role="alert"]');
}

/** Fill the token field and click submit in one call. */
Expand Down
42 changes: 36 additions & 6 deletions app/packages/web/e2e/specs/auth-gate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { test, expect, stubApis, ENV_DATA, COST_DATA } from '../fixtures/index.j

/**
* Auth-gate specs use the base Playwright `page` (no pre-seeded token) so they
* can verify the 401 → modal and token-save → reload flows in isolation. The
* `authGate` page object encapsulates the modal locators; raw `page` is still
* needed for direct route stubbing and `addInitScript`.
* can verify the 401 → modal and token-save → inline-retry flows in isolation.
* The `authGate` page object encapsulates the modal locators; raw `page` is
* still needed for direct route stubbing and `addInitScript`.
*/

test.describe('auth gate', () => {
Expand All @@ -27,7 +27,7 @@ test.describe('auth gate', () => {
await expect(authGate.modalHeading()).not.toBeVisible();
});

test('should save token and show dashboard after reload', async ({ page, authGate, layout }) => {
test('should save token and dismiss modal without reloading', async ({ page, authGate, layout }) => {
// Playwright matches routes in REVERSE registration order, so register the
// catch-all 404 FIRST and the specific 401/200 handlers after — otherwise
// the catch-all takes precedence and the modal never triggers.
Expand Down Expand Up @@ -63,10 +63,40 @@ test.describe('auth gate', () => {
await page.goto('/');
await expect(authGate.modalHeading()).toBeVisible();

await authGate.submit('my-test-token');
// Inline validation requires ≥16 chars, no whitespace.
await authGate.submit('my-test-token-12345');

// After reload the stored token is sent; the modal must be gone.
// Modal dismisses inline (no reload) and the dashboard surfaces.
await expect(layout.brandHeading()).toBeVisible();
await expect(authGate.modalHeading()).not.toBeVisible();
});

test('should show inline validation error for a too-short token', async ({ page, authGate }) => {
await page.route('**/api/**', (route) =>
route.fulfill({ status: 401, body: 'Unauthorized' })
);
await page.goto('/');
await expect(authGate.modalHeading()).toBeVisible();

await authGate.submit('short');

await expect(page.getByText(/at least 16 characters/i)).toBeVisible();
// Modal stays open — validation never reached the network.
await expect(authGate.modalHeading()).toBeVisible();
});

test('should show inline server error when retry returns 401 again', async ({ page, authGate }) => {
await page.route('**/api/**', (route) =>
route.fulfill({ status: 401, body: 'Unauthorized' })
);
await page.goto('/');
await expect(authGate.modalHeading()).toBeVisible();

await authGate.submit('still-the-wrong-token-1234');

await expect(
page.getByText(/Invalid token — check `app\/server_config\.json` or `API_TOKEN`\./i),
).toBeVisible();
await expect(authGate.modalHeading()).toBeVisible();
});
});
9 changes: 4 additions & 5 deletions app/packages/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import { LogsPage } from './pages/LogsPage.js';
import { SettingsPage } from './pages/SettingsPage.js';

/**
* Root component. Wires up the 401 handler on `api.ts` and renders either the
* token-prompt modal or the routed dashboard shell. The shell has a persistent
* layout (sidebar + top bar) and five routes:
* Root component. Wires up the 401 handler on `api.ts` and renders the routed
* dashboard shell with the API token dialog overlaid when an `/api/*` request
* has been parked on a 401. Five routes:
* - `/` → Dashboard (game cards + panels)
* - `/costs` → Cost analysis placeholder
* - `/discord` → Discord settings placeholder
Expand All @@ -30,10 +30,9 @@ export default function App() {
return () => setUnauthorizedHandler(null);
}, []);

if (needsToken) return <ApiTokenModal />;

return (
<BrowserRouter>
<ApiTokenModal open={needsToken} onSuccess={() => setNeedsToken(false)} />
<AppLayout>
<Routes>
<Route path="/" element={<DashboardPage />} />
Expand Down
67 changes: 64 additions & 3 deletions app/packages/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,20 +134,81 @@ export function setUnauthorizedHandler(h: (() => void) | null): void {
unauthorizedHandler = h;
}

async function request<T>(url: string, init?: RequestInit): Promise<T> {
interface PendingRetry {
url: string;
init: RequestInit | undefined;
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
}

/**
* Requests parked when the server returned 401. They wait for the operator to
* supply a valid token via `ApiTokenModal`, at which point `retryPendingAfterAuth`
* re-issues each one with the new bearer.
*/
const pendingRetries: PendingRetry[] = [];

/** Internal sentinel — `request` recognises it and parks the caller's promise; `retryPendingAfterAuth` recognises it on a retry to mark the new token as still bad. */
const UNAUTHORIZED = Symbol('api.unauthorized');

async function fetchWithAuth<T>(url: string, init?: RequestInit): Promise<T> {
const token = getStoredApiToken();
const headers = new Headers(init?.headers);
if (token) headers.set('Authorization', `Bearer ${token}`);
const res = await fetch(url, { ...init, headers });
if (res.status === 401) {
setStoredApiToken('');
unauthorizedHandler?.();
return new Promise<T>(() => undefined);
throw UNAUTHORIZED;
}
if (!res.ok) throw new Error(`API error ${res.status}`);
return res.json() as Promise<T>;
}

async function request<T>(url: string, init?: RequestInit): Promise<T> {
try {
return await fetchWithAuth<T>(url, init);
} catch (err) {
if (err !== UNAUTHORIZED) throw err;
return new Promise<T>((resolve, reject) => {
pendingRetries.push({
url,
init,
resolve: resolve as (value: unknown) => void,
reject,
});
unauthorizedHandler?.();
});
}
}

/**
* Re-issues every request that was parked on a 401 using the now-current
* stored token. Called by `ApiTokenModal` after the operator supplies a token.
*
* @returns `true` if every retry succeeded (the modal can dismiss);
* `false` if any retry returned 401 again (the modal stays open and
* shows an inline error). Requests that 401 again stay queued so the
* next save attempt retries them.
*/
export async function retryPendingAfterAuth(): Promise<boolean> {
const queued = pendingRetries.splice(0);
let allOk = true;
for (const entry of queued) {
try {
const value = await fetchWithAuth(entry.url, entry.init);
entry.resolve(value);
} catch (err) {
if (err === UNAUTHORIZED) {
allOk = false;
pendingRetries.push(entry);
} else {
entry.reject(err);
}
}
}
return allOk;
}

export const api = {
env: () => request<EnvInfo>('/api/env'),
games: () => request<{ games: string[] }>('/api/games'),
Expand Down
153 changes: 153 additions & 0 deletions app/packages/web/src/components/ApiTokenModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

/** Hoisted mocks for api.ts — vi.hoisted ensures they exist when vi.mock runs. */
const { retryMock, setTokenMock } = vi.hoisted(() => ({
retryMock: vi.fn<() => Promise<boolean>>(),
setTokenMock: vi.fn<(token: string) => void>(),
}));

vi.mock('../api.js', () => ({
retryPendingAfterAuth: retryMock,
setStoredApiToken: setTokenMock,
}));

import { ApiTokenModal } from './ApiTokenModal.js';

/** Helper: renders the modal with default open=true and a fresh onSuccess spy. */
function renderModal(open = true, onSuccess = vi.fn()) {
render(<ApiTokenModal open={open} onSuccess={onSuccess} />);
return { onSuccess };
}

const VALID_TOKEN = 'a-valid-token-12345';

describe('ApiTokenModal', () => {
beforeEach(() => {
retryMock.mockResolvedValue(true);
});

// ── Visibility ────────────────────────────────────────────────────────────

it('should render the API token required heading when open', () => {
renderModal();
expect(screen.getByRole('heading', { name: 'API token required' })).toBeInTheDocument();
});

it('should not render dialog content when open is false', () => {
renderModal(false);
expect(screen.queryByRole('heading', { name: 'API token required' })).not.toBeInTheDocument();
});

// ── Password field ────────────────────────────────────────────────────────

it('should render the token input in password mode', () => {
renderModal();
expect(screen.getByPlaceholderText('Paste API token')).toHaveAttribute('type', 'password');
});

it('should reveal the token when the show toggle is clicked', async () => {
const user = userEvent.setup();
renderModal();
await user.click(screen.getByRole('button', { name: 'Show token' }));
expect(screen.getByPlaceholderText('Paste API token')).toHaveAttribute('type', 'text');
});

it('should obscure the token again when the hide toggle is clicked', async () => {
const user = userEvent.setup();
renderModal();
await user.click(screen.getByRole('button', { name: 'Show token' }));
await user.click(screen.getByRole('button', { name: 'Hide token' }));
expect(screen.getByPlaceholderText('Paste API token')).toHaveAttribute('type', 'password');
});

// ── Inline validation ─────────────────────────────────────────────────────

it('should show a validation error when the token contains whitespace', async () => {
const user = userEvent.setup();
renderModal();
await user.type(screen.getByPlaceholderText('Paste API token'), 'has a space');
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
expect(screen.getByRole('alert')).toHaveTextContent('Token cannot contain whitespace.');
});

it('should show a validation error for a token shorter than 16 characters', async () => {
const user = userEvent.setup();
renderModal();
await user.type(screen.getByPlaceholderText('Paste API token'), 'tooshort');
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
expect(screen.getByRole('alert')).toHaveTextContent('at least 16 characters');
});

it('should re-validate on input change once a validation error is visible', async () => {
const user = userEvent.setup();
renderModal();
await user.type(screen.getByPlaceholderText('Paste API token'), 'tooshort');
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
expect(screen.getByRole('alert')).toBeInTheDocument();
// Extend to a valid-length token — the live re-validate should clear the error.
await user.type(screen.getByPlaceholderText('Paste API token'), '-now-long-enough');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

it('should not send a request when client validation fails', async () => {
const user = userEvent.setup();
renderModal();
await user.type(screen.getByPlaceholderText('Paste API token'), 'short');
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
expect(retryMock).not.toHaveBeenCalled();
expect(setTokenMock).not.toHaveBeenCalled();
});

// ── Submission ────────────────────────────────────────────────────────────

it('should call setStoredApiToken then retryPendingAfterAuth on valid submit', async () => {
const user = userEvent.setup();
renderModal();
await user.type(screen.getByPlaceholderText('Paste API token'), VALID_TOKEN);
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
expect(setTokenMock).toHaveBeenCalledWith(VALID_TOKEN);
await waitFor(() => expect(retryMock).toHaveBeenCalledOnce());
});

it('should call onSuccess when retryPendingAfterAuth resolves true', async () => {
const user = userEvent.setup();
const { onSuccess } = renderModal();
await user.type(screen.getByPlaceholderText('Paste API token'), VALID_TOKEN);
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce());
});

it('should show an inline server error when retryPendingAfterAuth resolves false', async () => {
const user = userEvent.setup();
retryMock.mockResolvedValue(false);
renderModal();
await user.type(screen.getByPlaceholderText('Paste API token'), VALID_TOKEN);
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
await waitFor(() =>
expect(screen.getByRole('alert')).toHaveTextContent(/Invalid token/),
);
});

it('should not call onSuccess when retryPendingAfterAuth resolves false', async () => {
const user = userEvent.setup();
retryMock.mockResolvedValue(false);
const { onSuccess } = renderModal();
await user.type(screen.getByPlaceholderText('Paste API token'), VALID_TOKEN);
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
expect(onSuccess).not.toHaveBeenCalled();
});

it('should clear the server error when the token input changes', async () => {
const user = userEvent.setup();
retryMock.mockResolvedValue(false);
renderModal();
await user.type(screen.getByPlaceholderText('Paste API token'), VALID_TOKEN);
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
await user.type(screen.getByPlaceholderText('Paste API token'), 'x');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
Loading
Loading