Skip to content

Commit e0304fe

Browse files
committed
test(web): add ApiTokenModal unit tests; fix API_TOKEN docs wording
- Add 14 Vitest+RTL specs covering: dialog visibility, password show/hide toggle, inline validation (whitespace / short token / live re-validate), submission → setStoredApiToken + retryPendingAfterAuth, onSuccess on accepted token, inline server error on 401 retry, and server-error cleared on input change. - Correct the API_TOKEN bullet in docs/docs/setup.md: empty env var prevents the config file from being consulted but is not a supported way to disable auth (production startup fails either way); both sources satisfy the production non-empty-token requirement. https://claude.ai/code/session_01WjsbdUtxDJZL3GaAvhyzav
1 parent bf4978c commit e0304fe

2 files changed

Lines changed: 161 additions & 5 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, screen, waitFor } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
5+
/** Hoisted mocks for api.ts — vi.hoisted ensures they exist when vi.mock runs. */
6+
const { retryMock, setTokenMock } = vi.hoisted(() => ({
7+
retryMock: vi.fn<() => Promise<boolean>>(),
8+
setTokenMock: vi.fn<(token: string) => void>(),
9+
}));
10+
11+
vi.mock('../api.js', () => ({
12+
retryPendingAfterAuth: retryMock,
13+
setStoredApiToken: setTokenMock,
14+
}));
15+
16+
import { ApiTokenModal } from './ApiTokenModal.js';
17+
18+
/** Helper: renders the modal with default open=true and a fresh onSuccess spy. */
19+
function renderModal(open = true, onSuccess = vi.fn()) {
20+
render(<ApiTokenModal open={open} onSuccess={onSuccess} />);
21+
return { onSuccess };
22+
}
23+
24+
const VALID_TOKEN = 'a-valid-token-12345';
25+
26+
describe('ApiTokenModal', () => {
27+
beforeEach(() => {
28+
retryMock.mockResolvedValue(true);
29+
});
30+
31+
// ── Visibility ────────────────────────────────────────────────────────────
32+
33+
it('should render the API token required heading when open', () => {
34+
renderModal();
35+
expect(screen.getByRole('heading', { name: 'API token required' })).toBeInTheDocument();
36+
});
37+
38+
it('should not render dialog content when open is false', () => {
39+
renderModal(false);
40+
expect(screen.queryByRole('heading', { name: 'API token required' })).not.toBeInTheDocument();
41+
});
42+
43+
// ── Password field ────────────────────────────────────────────────────────
44+
45+
it('should render the token input in password mode', () => {
46+
renderModal();
47+
expect(screen.getByPlaceholderText('Paste API token')).toHaveAttribute('type', 'password');
48+
});
49+
50+
it('should reveal the token when the show toggle is clicked', async () => {
51+
const user = userEvent.setup();
52+
renderModal();
53+
await user.click(screen.getByRole('button', { name: 'Show token' }));
54+
expect(screen.getByPlaceholderText('Paste API token')).toHaveAttribute('type', 'text');
55+
});
56+
57+
it('should obscure the token again when the hide toggle is clicked', async () => {
58+
const user = userEvent.setup();
59+
renderModal();
60+
await user.click(screen.getByRole('button', { name: 'Show token' }));
61+
await user.click(screen.getByRole('button', { name: 'Hide token' }));
62+
expect(screen.getByPlaceholderText('Paste API token')).toHaveAttribute('type', 'password');
63+
});
64+
65+
// ── Inline validation ─────────────────────────────────────────────────────
66+
67+
it('should show a validation error when the token contains whitespace', async () => {
68+
const user = userEvent.setup();
69+
renderModal();
70+
await user.type(screen.getByPlaceholderText('Paste API token'), 'has a space');
71+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
72+
expect(screen.getByRole('alert')).toHaveTextContent('Token cannot contain whitespace.');
73+
});
74+
75+
it('should show a validation error for a token shorter than 16 characters', async () => {
76+
const user = userEvent.setup();
77+
renderModal();
78+
await user.type(screen.getByPlaceholderText('Paste API token'), 'tooshort');
79+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
80+
expect(screen.getByRole('alert')).toHaveTextContent('at least 16 characters');
81+
});
82+
83+
it('should re-validate on input change once a validation error is visible', async () => {
84+
const user = userEvent.setup();
85+
renderModal();
86+
await user.type(screen.getByPlaceholderText('Paste API token'), 'tooshort');
87+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
88+
expect(screen.getByRole('alert')).toBeInTheDocument();
89+
// Extend to a valid-length token — the live re-validate should clear the error.
90+
await user.type(screen.getByPlaceholderText('Paste API token'), '-now-long-enough');
91+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
92+
});
93+
94+
it('should not send a request when client validation fails', async () => {
95+
const user = userEvent.setup();
96+
renderModal();
97+
await user.type(screen.getByPlaceholderText('Paste API token'), 'short');
98+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
99+
expect(retryMock).not.toHaveBeenCalled();
100+
expect(setTokenMock).not.toHaveBeenCalled();
101+
});
102+
103+
// ── Submission ────────────────────────────────────────────────────────────
104+
105+
it('should call setStoredApiToken then retryPendingAfterAuth on valid submit', async () => {
106+
const user = userEvent.setup();
107+
renderModal();
108+
await user.type(screen.getByPlaceholderText('Paste API token'), VALID_TOKEN);
109+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
110+
expect(setTokenMock).toHaveBeenCalledWith(VALID_TOKEN);
111+
await waitFor(() => expect(retryMock).toHaveBeenCalledOnce());
112+
});
113+
114+
it('should call onSuccess when retryPendingAfterAuth resolves true', async () => {
115+
const user = userEvent.setup();
116+
const { onSuccess } = renderModal();
117+
await user.type(screen.getByPlaceholderText('Paste API token'), VALID_TOKEN);
118+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
119+
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce());
120+
});
121+
122+
it('should show an inline server error when retryPendingAfterAuth resolves false', async () => {
123+
const user = userEvent.setup();
124+
retryMock.mockResolvedValue(false);
125+
renderModal();
126+
await user.type(screen.getByPlaceholderText('Paste API token'), VALID_TOKEN);
127+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
128+
await waitFor(() =>
129+
expect(screen.getByRole('alert')).toHaveTextContent(/Invalid token/),
130+
);
131+
});
132+
133+
it('should not call onSuccess when retryPendingAfterAuth resolves false', async () => {
134+
const user = userEvent.setup();
135+
retryMock.mockResolvedValue(false);
136+
const { onSuccess } = renderModal();
137+
await user.type(screen.getByPlaceholderText('Paste API token'), VALID_TOKEN);
138+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
139+
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
140+
expect(onSuccess).not.toHaveBeenCalled();
141+
});
142+
143+
it('should clear the server error when the token input changes', async () => {
144+
const user = userEvent.setup();
145+
retryMock.mockResolvedValue(false);
146+
renderModal();
147+
await user.type(screen.getByPlaceholderText('Paste API token'), VALID_TOKEN);
148+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
149+
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
150+
await user.type(screen.getByPlaceholderText('Paste API token'), 'x');
151+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
152+
});
153+
});

docs/docs/setup.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -295,12 +295,15 @@ The dashboard API is gated behind a bearer token; `/api/*` requests without
295295
a matching `Authorization: Bearer …` header return 401. There are two ways
296296
to configure the value, in priority order:
297297

298-
1. **`API_TOKEN` environment variable** — wins, even when set to empty (use
299-
this to deliberately disable auth in scripts/CI). Required when running
300-
under `NODE_ENV=production`; the server refuses to boot otherwise.
298+
1. **`API_TOKEN` environment variable** — takes precedence over
299+
`server_config.json` when set, including when set to empty. An empty
300+
value is normalized to "no token configured" and prevents the config
301+
file from being consulted, but it is **not** a supported way to disable
302+
auth — `NODE_ENV=production` startup fails when neither source supplies
303+
a non-empty token.
301304
2. **`api_token` field in `app/server_config.json`** — the persisted file
302-
bind-mounted by `docker-compose.yml`. Edit it directly or let the
303-
server write it via `/api/config`.
305+
bind-mounted by `docker-compose.yml`. Used when `API_TOKEN` is absent.
306+
Edit it directly or let the server write it via `/api/config`.
304307

305308
Generate a fresh token with `openssl rand -hex 32`. The dashboard prompts
306309
for it on first load (and any time the server returns 401); paste the

0 commit comments

Comments
 (0)