@johnnyboy11234/esg-idea-contest 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/backend/api/openapi.yaml +36 -0
  2. package/backend/cmd/server/main.go +1 -0
  3. package/backend/internal/account/internal/passwords/NOTICE +36 -0
  4. package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
  5. package/backend/internal/account/internal/passwords/passwords.go +37 -0
  6. package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
  7. package/backend/internal/account/password.go +8 -2
  8. package/backend/internal/account/sequence.go +54 -0
  9. package/backend/internal/account/sequence_test.go +38 -0
  10. package/backend/internal/api/api.gen.go +38 -0
  11. package/backend/internal/idea/create.go +5 -1
  12. package/backend/internal/idea/create_integration_test.go +33 -3
  13. package/backend/internal/idea/download.go +193 -0
  14. package/backend/internal/idea/download_integration_test.go +311 -0
  15. package/backend/internal/idea/download_test.go +25 -0
  16. package/backend/internal/idea/handler.go +3 -0
  17. package/backend/internal/idea/service.go +2 -1
  18. package/backend/internal/server/api.go +5 -0
  19. package/backend/internal/server/binding.go +4 -0
  20. package/backend/internal/server/server.go +20 -1
  21. package/backend/internal/server/slides_test.go +82 -0
  22. package/backend/internal/shared/web/transport.go +4 -3
  23. package/backend/test/arch/arch_test.go +5 -1
  24. package/backend/test/matrix/matrix_integration_test.go +6 -0
  25. package/docs/Architecture/Architecture.md +1 -1
  26. package/docs/Architecture/Backend.md +2 -1
  27. package/frontend/Dockerfile +2 -0
  28. package/frontend/package-lock.json +1 -0
  29. package/frontend/package.json +1 -0
  30. package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
  31. package/frontend/src/app/pages/ideas.tsx +23 -9
  32. package/frontend/src/app/pages/login.test.tsx +38 -1
  33. package/frontend/src/app/pages/register.test.tsx +32 -0
  34. package/frontend/src/features/auth/LoginForm.tsx +21 -3
  35. package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
  36. package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
  37. package/frontend/src/features/auth/RegisterForm.tsx +11 -2
  38. package/frontend/src/features/auth/common-passwords.ts +31 -0
  39. package/frontend/src/features/auth/password-policy.test.ts +64 -0
  40. package/frontend/src/features/auth/password-policy.ts +168 -0
  41. package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
  42. package/frontend/src/features/auth/useCountdown.ts +28 -0
  43. package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
  44. package/frontend/src/features/idea/create.ts +123 -0
  45. package/frontend/src/shared/api/types.generated.ts +319 -1
  46. package/frontend/src/shared/i18n/locales/en.ts +45 -4
  47. package/frontend/src/shared/i18n/locales/zh-TW.ts +45 -4
  48. package/frontend/src/shared/slides/index.ts +74 -0
  49. package/frontend/src/shared/slides/slides.test.ts +51 -0
  50. package/frontend/src/shared/ui/copy-button.tsx +52 -0
  51. package/frontend/src/shared/ui/native-select.tsx +27 -0
  52. package/frontend/src/shared/ui/textarea.tsx +19 -0
  53. package/frontend/src/test-setup.ts +37 -2
  54. package/frontend/vite.config.ts +3 -1
  55. package/package.json +1 -1
  56. package/testdata/password.json +164 -7
  57. package/testdata/permission-matrix.json +23 -0
  58. package/testdata/schema/permission-matrix.schema.json +1 -1
@@ -0,0 +1,308 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+ import { render, screen, waitFor, within } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { http, HttpResponse } from 'msw';
5
+ import { createMemoryRouter } from 'react-router';
6
+ import { axe } from 'vitest-axe';
7
+ import { clearCsrfToken } from '@/shared/api';
8
+ import { problem } from '@/shared/api/mocks/problem';
9
+ import { server } from '@/shared/api/mocks/server';
10
+ import { initI18n, type Locale } from '@/shared/i18n';
11
+ import { App } from '../App';
12
+ import { routes } from '../router';
13
+
14
+ const API = 'http://localhost:3000/api/v1';
15
+ const ME = { empNo: '089290', name: '王小明', email: 'wang@example.com', role: 'USER' };
16
+ const BASE = 'I_BSID_王小明_089290_成立供應商永續學院';
17
+ const PDF = '%PDF-1.7\n';
18
+ const ID = '0193a1b2-0000-7000-8000-000000000001';
19
+
20
+ type State = 'OPEN' | 'CLOSED' | 'NOT_OPEN';
21
+
22
+ interface Sent {
23
+ fields: Record<string, string>;
24
+ file: { name: string; size: number } | null;
25
+ csrf: string | null;
26
+ }
27
+
28
+ async function open({ locale = 'en', state = 'OPEN' as State } = {}) {
29
+ clearCsrfToken();
30
+ server.use(
31
+ http.get(`${API}/auth/me`, () => HttpResponse.json(ME)),
32
+ http.get(`${API}/deadline`, () => HttpResponse.json({ deadline: null, state, serverTime: '2026-11-08T00:00:00.000Z' })),
33
+ http.get(`${API}/auth/csrf`, () => HttpResponse.json({ token: 'tok' })),
34
+ );
35
+ await initI18n(locale as Locale);
36
+ const router = createMemoryRouter(routes, { initialEntries: ['/ideas/new'] });
37
+ const client = new QueryClient();
38
+ const view = render(<App router={router} client={client} />);
39
+ await screen.findByRole('heading', { level: 1, name: locale === 'en' ? 'Create an Idea' : '建立提案' });
40
+ return { router, client, user: userEvent.setup(), ...view };
41
+ }
42
+
43
+ function createReturns(respond: () => Response | Promise<Response>) {
44
+ const sent: Sent[] = [];
45
+ server.use(
46
+ http.post(`${API}/ideas`, async ({ request }) => {
47
+ const form = await request.formData();
48
+ const fields: Record<string, string> = {};
49
+ let file: Sent['file'] = null;
50
+ for (const [k, v] of form.entries()) {
51
+ if (typeof v === 'string') fields[k] = v;
52
+ else file = { name: v.name, size: v.size };
53
+ }
54
+ sent.push({ fields, file, csrf: request.headers.get('X-CSRF-Token') });
55
+ return respond();
56
+ }),
57
+ );
58
+ return sent;
59
+ }
60
+
61
+ type User = ReturnType<typeof userEvent.setup>;
62
+
63
+ async function fillText(user: User, v: { name?: string; summary?: string } = {}) {
64
+ await user.selectOptions(screen.getByLabelText('Category'), 'COOL_IDEA');
65
+ await user.selectOptions(screen.getByLabelText('Organization'), 'BSID');
66
+ await user.selectOptions(screen.getByLabelText('Direction'), 'RESPONSIBLE_SUPPLY_CHAIN');
67
+ await user.type(screen.getByLabelText('Idea name'), v.name ?? ' 成立供應商永續學院 ');
68
+ await user.type(screen.getByLabelText('Summary'), v.summary ?? 'Train suppliers.');
69
+ }
70
+
71
+ const pick = (user: User, name: string, content: string = PDF) => user.upload(screen.getByLabelText('Slide file'), new File([content], name));
72
+
73
+ const submit = (user: User) => user.click(screen.getByRole('button', { name: 'Submit Idea' }));
74
+
75
+ describe('Create Idea page: markup and accessibility', () => {
76
+ it('labels and requires every field; the Leader comes from the session', async () => {
77
+ await open();
78
+ for (const label of ['Category', 'Organization', 'Direction', 'Idea name', 'Summary', 'Slide file']) {
79
+ expect(screen.getByLabelText(label)).toBeRequired();
80
+ }
81
+ expect(screen.getByText('王小明 (089290)')).toBeInTheDocument();
82
+ expect(screen.getByLabelText('Slide file')).toHaveAttribute('type', 'file');
83
+ expect(screen.getByLabelText('Idea name')).not.toHaveAttribute('maxlength'); // counted in code points, not UTF-16
84
+ });
85
+
86
+ it('shows the zh-TW working-draft labels (OI-17)', async () => {
87
+ await open({ locale: 'zh-TW' });
88
+ const options = within(screen.getByLabelText('類別')).getAllByRole('option').map((o) => o.textContent);
89
+ expect(options).toEqual(['請選擇…', '酷點子獎', '卓越影響獎']);
90
+ expect(within(screen.getByLabelText('方向')).getByRole('option', { name: '關懷弱勢' })).toBeInTheDocument();
91
+ });
92
+
93
+ it.each(['en', 'zh-TW'] as const)('has no axe violations (%s), with the expected name and with errors shown', async (locale) => {
94
+ const { container, user } = await open({ locale });
95
+ expect((await axe(container)).violations).toEqual([]);
96
+ if (locale === 'en') {
97
+ await fillText(user);
98
+ expect(await screen.findByText(`${BASE}.pdf`)).toBeInTheDocument();
99
+ expect((await axe(container)).violations).toEqual([]);
100
+ }
101
+ await user.click(screen.getByRole('button', { name: locale === 'en' ? 'Submit Idea' : '送出提案' }));
102
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
103
+ expect((await axe(container)).violations).toEqual([]);
104
+ });
105
+ });
106
+
107
+ describe('Create Idea page: code-point counters', () => {
108
+ it('count the cleaned value in code points and are linked to their field', async () => {
109
+ const { user } = await open();
110
+ const name = screen.getByLabelText('Idea name');
111
+ expect(name.getAttribute('aria-describedby')).toContain('name-count');
112
+ expect(document.getElementById('name-count')).toHaveTextContent('0 / 75 characters');
113
+ await user.type(name, ' 😀😀 a\tb ');
114
+ expect(document.getElementById('name-count')).toHaveTextContent('6 / 75 characters'); // "😀😀 a b"
115
+ await user.type(screen.getByLabelText('Summary'), '創意\n\n😀');
116
+ expect(document.getElementById('summary-count')).toHaveTextContent('5 / 300 characters');
117
+ });
118
+
119
+ it('76 code points are rejected before any request (OUT_OF_RANGE)', async () => {
120
+ const { user } = await open();
121
+ const sent = createReturns(() => HttpResponse.json({}, { status: 201 }));
122
+ await fillText(user, { name: '😀'.repeat(76) });
123
+ expect(document.getElementById('name-count')).toHaveTextContent('76 / 75 characters');
124
+ await submit(user);
125
+ expect(await screen.findByRole('link', { name: 'Idea name: The value is out of range.' })).toBeInTheDocument();
126
+ expect(sent).toHaveLength(0);
127
+ });
128
+ });
129
+
130
+ describe('Create Idea page: expected filename (PRD §6.8, OI-21)', () => {
131
+ it('appears once category, org and name are given, for both extensions, from the cleaned name', async () => {
132
+ const { user } = await open();
133
+ expect(screen.getByText(/to see the expected file name/)).toBeInTheDocument();
134
+ await fillText(user);
135
+ expect(screen.getByText(`${BASE}.pdf`)).toBeInTheDocument();
136
+ expect(screen.getByText(`${BASE}.pptx`)).toBeInTheDocument();
137
+ await user.selectOptions(screen.getByLabelText('Category'), 'OUTSTANDING_IMPACT');
138
+ expect(screen.getByText(`O${BASE.slice(1)}.pdf`)).toBeInTheDocument();
139
+ });
140
+
141
+ it('the copy buttons are named and copy the full name; the result is announced', async () => {
142
+ const { user } = await open();
143
+ await fillText(user);
144
+ await user.click(screen.getByRole('button', { name: 'Copy the PPTX file name' }));
145
+ expect(await navigator.clipboard.readText()).toBe(`${BASE}.pptx`);
146
+ expect(screen.getByText('File name copied.')).toHaveAttribute('role', 'status');
147
+ });
148
+
149
+ it('a name whose expected filename exceeds 255 UTF-8 bytes is FILENAME_TOO_LONG on the name', async () => {
150
+ const { user } = await open();
151
+ const sent = createReturns(() => HttpResponse.json({}, { status: 201 }));
152
+ await fillText(user, { name: '創'.repeat(75) }); // 75 code points, 225 bytes in the name alone
153
+ await pick(user, 'x.pdf');
154
+ await submit(user);
155
+ expect(await screen.findByRole('link', { name: 'Idea name: The file name is too long.' })).toBeInTheDocument();
156
+ expect(sent).toHaveLength(0);
157
+ });
158
+ });
159
+
160
+ describe('Create Idea page: pre-check', () => {
161
+ it('an empty submit lists every field, focuses the summary and sends nothing', async () => {
162
+ const { user } = await open();
163
+ const sent = createReturns(() => HttpResponse.json({}, { status: 201 }));
164
+ await submit(user);
165
+ const summary = await screen.findByRole('alert');
166
+ await waitFor(() => expect(summary).toHaveFocus());
167
+ expect(within(summary).getAllByRole('link').map((l) => l.textContent)).toEqual([
168
+ 'Category: This field is required.',
169
+ 'Organization: This field is required.',
170
+ 'Direction: This field is required.',
171
+ 'Idea name: This field is required.',
172
+ 'Summary: This field is required.',
173
+ 'Slide file: This field is required.',
174
+ ]);
175
+ const slides = screen.getByLabelText('Slide file');
176
+ expect(slides).toHaveAttribute('aria-invalid', 'true');
177
+ expect(slides.getAttribute('aria-describedby')).toContain('slides-error');
178
+ await user.click(within(summary).getByRole('link', { name: /^Summary/ }));
179
+ expect(screen.getByLabelText('Summary')).toHaveFocus();
180
+ expect(sent).toHaveLength(0);
181
+ });
182
+
183
+ it('a name with a forbidden character: text errors only, the file is not checked yet', async () => {
184
+ const { user } = await open();
185
+ await fillText(user, { name: 'a/b' });
186
+ await pick(user, 'wrong.pdf');
187
+ await submit(user);
188
+ const summary = await screen.findByRole('alert');
189
+ expect(within(summary).getAllByRole('link').map((l) => l.textContent)).toEqual(['Idea name: The name contains characters that are not allowed.']);
190
+ });
191
+
192
+ it.each([
193
+ ['a wrong name', 'slides.pdf', PDF, `Slide file: The file name does not match the expected name. Expected: ${BASE}.pdf`],
194
+ ['an empty file', `${BASE}.pdf`, '', 'Slide file: The file is empty.'],
195
+ ['PDF bytes named .pptx', `${BASE}.pptx`, PDF, 'Slide file: This file type is not supported.'],
196
+ ])('%s is rejected before upload', async (_n, name, content, text) => {
197
+ const { user } = await open();
198
+ const sent = createReturns(() => HttpResponse.json({}, { status: 201 }));
199
+ await fillText(user);
200
+ await pick(user, name, content);
201
+ await submit(user);
202
+ expect(await screen.findByRole('link', { name: text })).toBeInTheDocument();
203
+ expect(sent).toHaveLength(0);
204
+ });
205
+ });
206
+
207
+ describe('Create Idea page: submit', () => {
208
+ it('sends one multipart request with the cleaned fields, the file and the CSRF token, then opens the new Idea', async () => {
209
+ const { user, router, client } = await open();
210
+ const detail = { id: ID, name: '成立供應商永續學院', expectedFilenameBase: BASE };
211
+ const sent = createReturns(() => HttpResponse.json(detail, { status: 201, headers: { Location: `/api/v1/ideas/${ID}` } }));
212
+ await fillText(user, { summary: ' Line 1\r\nLine 2 ' });
213
+ await pick(user, `${BASE}.pdf`);
214
+ await submit(user);
215
+ await waitFor(() => expect(router.state.location.pathname).toBe(`/ideas/${ID}`));
216
+ expect(sent).toEqual([
217
+ {
218
+ fields: { category: 'COOL_IDEA', org: 'BSID', direction: 'RESPONSIBLE_SUPPLY_CHAIN', name: '成立供應商永續學院', summary: 'Line 1\nLine 2' },
219
+ file: { name: `${BASE}.pdf`, size: PDF.length },
220
+ csrf: 'tok',
221
+ },
222
+ ]);
223
+ expect(client.getQueryData(['idea', ID])).toEqual(detail);
224
+ });
225
+
226
+ it('the button is disabled while the request is pending (no double submit)', async () => {
227
+ const { user, router } = await open();
228
+ let release!: () => void;
229
+ const gate = new Promise<void>((r) => (release = r));
230
+ const sent = createReturns(async () => {
231
+ await gate;
232
+ return HttpResponse.json({ id: ID }, { status: 201 });
233
+ });
234
+ await fillText(user);
235
+ await pick(user, `${BASE}.pdf`);
236
+ await submit(user);
237
+ expect(await screen.findByRole('button', { name: 'Submitting…' })).toBeDisabled();
238
+ release();
239
+ await waitFor(() => expect(router.state.location.pathname).toBe(`/ideas/${ID}`));
240
+ expect(sent).toHaveLength(1);
241
+ });
242
+
243
+ it('outside the submission window the button is disabled with a reason (not a guard)', async () => {
244
+ await open({ state: 'CLOSED' });
245
+ expect(await screen.findByText('Submissions are not open, so the Idea cannot be submitted now.')).toBeInTheDocument();
246
+ expect(screen.getByRole('button', { name: 'Submit Idea' })).toBeDisabled();
247
+ expect(screen.getByLabelText('Idea name')).toBeEnabled();
248
+ });
249
+ });
250
+
251
+ describe('Create Idea page: server answers', () => {
252
+ async function submitValid(respond: () => Response) {
253
+ const ctx = await open();
254
+ const sent = createReturns(respond);
255
+ await fillText(ctx.user);
256
+ await pick(ctx.user, `${BASE}.pdf`);
257
+ await submit(ctx.user);
258
+ return { ...ctx, sent };
259
+ }
260
+
261
+ it('422 FILENAME_MISMATCH shows the server’s expected name on the file field', async () => {
262
+ const expected = 'I_BSID_王小明_089290_Other.pdf';
263
+ await submitValid(() => problem(422, 'FILENAME_MISMATCH', { errors: [{ field: 'slides', code: 'FILENAME_MISMATCH', expected }] }));
264
+ expect(await screen.findByRole('link', { name: `Slide file: The file name does not match the expected name. Expected: ${expected}` })).toBeInTheDocument();
265
+ });
266
+
267
+ it('409 IDEA_DUPLICATE belongs to the name; nothing typed is lost', async () => {
268
+ const { router } = await submitValid(() => problem(409, 'IDEA_DUPLICATE'));
269
+ expect(await screen.findByRole('link', { name: 'Idea name: You already have an Idea with this name.' })).toBeInTheDocument();
270
+ expect(screen.getByLabelText('Idea name')).toHaveAttribute('aria-invalid', 'true');
271
+ expect(screen.getByLabelText('Summary')).toHaveValue('Train suppliers.');
272
+ expect(router.state.location.pathname).toBe('/ideas/new');
273
+ });
274
+
275
+ it('422 text errors map to their fields', async () => {
276
+ await submitValid(() =>
277
+ problem(422, 'VALIDATION_FAILED', {
278
+ errors: [
279
+ { field: 'direction', code: 'INVALID_ENUM' },
280
+ { field: 'summary', code: 'OUT_OF_RANGE' },
281
+ ],
282
+ }),
283
+ );
284
+ expect(await screen.findByRole('link', { name: 'Direction: Please choose one of the listed options.' })).toBeInTheDocument();
285
+ expect(screen.getByRole('link', { name: 'Summary: The value is out of range.' })).toBeInTheDocument();
286
+ });
287
+
288
+ it.each([
289
+ ['413 from the ingress (not JSON)', () => new HttpResponse('<html>413</html>', { status: 413, headers: { 'Content-Type': 'text/html' } }), 'Slide file: The file is too large (limit 5 MiB).'],
290
+ ['415', () => problem(415, 'UNSUPPORTED_FILE_TYPE'), 'Slide file: This file type is not supported.'],
291
+ ['422 EMPTY_FILE', () => problem(422, 'EMPTY_FILE'), 'Slide file: The file is empty.'],
292
+ ])('%s is shown on the file field', async (_n, respond, text) => {
293
+ await submitValid(respond);
294
+ expect(await screen.findByRole('link', { name: text })).toBeInTheDocument();
295
+ });
296
+
297
+ it('403 SUBMISSION_CLOSED is a form message and refetches the deadline, which disables the button', async () => {
298
+ await submitValid(() => {
299
+ server.use(http.get(`${API}/deadline`, () => HttpResponse.json({ deadline: null, state: 'CLOSED', serverTime: '2026-11-08T00:00:00.000Z' })));
300
+ return problem(403, 'SUBMISSION_CLOSED');
301
+ });
302
+ const summary = await screen.findByRole('alert');
303
+ expect(summary).toHaveTextContent('Submissions are closed.');
304
+ expect(summary).toHaveTextContent('Request ID: 6f1c2c1e-0000-4000-8000-000000000000');
305
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Submit Idea' })).toBeDisabled());
306
+ expect(screen.getByText('Submissions are not open, so the Idea cannot be submitted now.')).toBeInTheDocument();
307
+ });
308
+ });
@@ -1,7 +1,9 @@
1
1
  import type { FormEvent, ReactNode } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
- import { Link } from 'react-router';
4
- import { useDeadline } from '@/features/deadline/useDeadline';
3
+ import { useQueryClient } from '@tanstack/react-query';
4
+ import { Link, useNavigate } from 'react-router';
5
+ import { deadlineKey, useDeadline } from '@/features/deadline/useDeadline';
6
+ import { CreateIdeaForm } from '@/features/idea/CreateIdeaForm';
5
7
  import { Button } from '@/shared/ui/button';
6
8
  import { Field } from '@/shared/ui/form-field';
7
9
  import { PageHeading } from '../layouts';
@@ -81,17 +83,29 @@ export function IdeaList() {
81
83
 
82
84
  export function IdeaCreate() {
83
85
  const { t } = useTranslation();
86
+ const { data: me } = useMe();
84
87
  const { data: deadline } = useDeadline();
85
- const open = deadline?.state === 'OPEN'; // Frontend.md §6.3 rule 6: a banner and disabled controls, not a guard
88
+ const client = useQueryClient();
89
+ const navigate = useNavigate();
90
+ const open = deadline?.state === 'OPEN'; // Frontend.md §6.3 rule 6: a hint and disabled controls, not a guard
86
91
  return (
87
92
  <>
88
93
  <PageHeading title={t('pages.ideaCreate.title')} />
89
- <form onSubmit={noSubmit} className="max-w-2xl rounded-xl border bg-card p-6">
90
- <IdeaFields withSlides />
91
- <Button type="submit" disabled={!open}>
92
- {t('pages.ideaCreate.submit')}
93
- </Button>
94
- </form>
94
+ <div className="max-w-2xl rounded-xl border bg-card p-6">
95
+ {me && (
96
+ <CreateIdeaForm
97
+ leader={{ name: me.name, empNo: me.empNo }}
98
+ canSubmit={open}
99
+ onSubmissionRejected={() => void client.invalidateQueries({ queryKey: deadlineKey })}
100
+ onCreated={(idea) => {
101
+ // the response is the detail: cache it, no follow-up GET (Frontend.md §6.7)
102
+ client.setQueryData(['idea', idea.id], idea);
103
+ void client.invalidateQueries({ queryKey: ['ideas'] });
104
+ void navigate(`/ideas/${idea.id}`);
105
+ }}
106
+ />
107
+ )}
108
+ </div>
95
109
  </>
96
110
  );
97
111
  }
@@ -1,5 +1,6 @@
1
1
  import { QueryClient } from "@tanstack/react-query";
2
- import { render, screen, waitFor } from "@testing-library/react";
2
+ import { act, render, screen, waitFor } from "@testing-library/react";
3
+ import { vi } from "vitest";
3
4
  import userEvent from "@testing-library/user-event";
4
5
  import { http, HttpResponse } from "msw";
5
6
  import { createMemoryRouter } from "react-router";
@@ -141,6 +142,42 @@ describe("Login page", () => {
141
142
  );
142
143
  });
143
144
 
145
+ it("disables sign-in for Retry-After seconds, then re-enables it", async () => {
146
+ // only the interval and the monotonic clock are faked; userEvent and msw keep real timers
147
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
148
+ let now = 1_000;
149
+ vi.spyOn(performance, "now").mockImplementation(() => now);
150
+ const advance = (ms: number) =>
151
+ act(() => {
152
+ now += ms;
153
+ vi.advanceTimersByTime(ms);
154
+ });
155
+ try {
156
+ const { user } = await open("/login");
157
+ server.use(
158
+ http.post(`${API}/auth/login`, () =>
159
+ problem(429, "LOGIN_THROTTLED", {}, { "Retry-After": "3" }),
160
+ ),
161
+ );
162
+ await signIn(user);
163
+ expect(
164
+ await screen.findByRole("button", { name: "Try again in 3 s" }),
165
+ ).toBeDisabled();
166
+ advance(1000);
167
+ expect(
168
+ screen.getByRole("button", { name: "Try again in 2 s" }),
169
+ ).toBeDisabled();
170
+ advance(2000);
171
+ expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled();
172
+ expect(screen.getByRole("status")).toHaveTextContent(
173
+ "You can sign in again.",
174
+ );
175
+ } finally {
176
+ vi.restoreAllMocks();
177
+ vi.useRealTimers();
178
+ }
179
+ });
180
+
144
181
  it("shows the registered and session-ended notices", async () => {
145
182
  const router = createMemoryRouter(routes, {
146
183
  initialEntries: [
@@ -138,6 +138,38 @@ describe('Register page: pre-check and error summary', () => {
138
138
  });
139
139
  });
140
140
 
141
+ describe('Register page: live password checklist (F1b-01)', () => {
142
+ const status = (rule: string) => document.querySelector(`li[data-rule="${rule}"]`)?.getAttribute('data-status');
143
+
144
+ it('the password field is described by the checklist, which follows typing and the employee ID', async () => {
145
+ const { user } = await open();
146
+ const password = screen.getByLabelText('Password');
147
+ expect(password.getAttribute('aria-describedby')).toContain('password-rules');
148
+ expect(document.getElementById('password-rules')).toHaveTextContent('Your password must:');
149
+ expect(status('LENGTH')).toBe('idle');
150
+
151
+ await fill(user, { 'Employee ID': '089290', Password: 'Password089290' });
152
+ await waitFor(() => expect(status('COMMON_PASSWORD')).toBe('met'));
153
+ expect(status('LENGTH')).toBe('met');
154
+ expect(status('CONTAINS_EMP_NO')).toBe('unmet');
155
+ });
156
+
157
+ it('the Top-10k blocklist (lazy chunk) marks a common password', async () => {
158
+ const { user } = await open();
159
+ await fill(user, { Password: 'password' });
160
+ await waitFor(() => expect(status('COMMON_PASSWORD')).toBe('unmet'));
161
+ });
162
+
163
+ it('does not block submit: the server stays the authority (422 PASSWORD_POLICY)', async () => {
164
+ const { seen, user } = await open();
165
+ registerReturns(seen, () => problem(422, 'PASSWORD_POLICY', { errors: [{ field: 'password', code: 'PASSWORD_POLICY', rule: 'LENGTH' }] }));
166
+ await fill(user, { ...VALID, Password: 'short', 'Confirm password': 'short' });
167
+ await user.click(screen.getByRole('button', { name: 'Create account' }));
168
+ expect(await screen.findByText('Password: The password does not meet the requirements.')).toBeInTheDocument();
169
+ expect(seen).toHaveLength(1);
170
+ });
171
+ });
172
+
141
173
  describe('Register page: submit', () => {
142
174
  it('sends the cleaned values with the CSRF token, then goes to /login with a notice and no login', async () => {
143
175
  const { user, seen, router } = await open();
@@ -12,6 +12,7 @@ import {
12
12
  } from "@/shared/ui/field";
13
13
  import { Input } from "@/shared/ui/input";
14
14
  import { login, type Me } from "./login";
15
+ import { useCountdown } from "./useCountdown";
15
16
 
16
17
  /**
17
18
  * The login form. Every credential failure is the same 401 `INVALID_CREDENTIALS` (PRD §6.5), so
@@ -33,6 +34,9 @@ export function LoginForm({
33
34
  const [errorKey, setErrorKey] = useState(0);
34
35
  const [pending, setPending] = useState(false);
35
36
  const alert = useRef<HTMLParagraphElement>(null);
37
+ const countdown = useCountdown();
38
+ const [wasThrottled, setWasThrottled] = useState(false);
39
+ const throttled = countdown.remaining > 0;
36
40
 
37
41
  // after the message is in the DOM (WCAG 3.3.1)
38
42
  useEffect(() => {
@@ -41,9 +45,10 @@ export function LoginForm({
41
45
 
42
46
  const onSubmit = async (e: FormEvent) => {
43
47
  e.preventDefault();
44
- if (pending) return;
48
+ if (pending || throttled) return;
45
49
  setPending(true);
46
50
  setError(null);
51
+ setWasThrottled(false);
47
52
  try {
48
53
  onLoggedIn(await login({ empNo: empNo.trim(), password }));
49
54
  } catch (err) {
@@ -51,6 +56,10 @@ export function LoginForm({
51
56
  err instanceof ApiError
52
57
  ? err
53
58
  : new ApiError({ status: 0, code: "UNKNOWN" });
59
+ if (apiErr.status === 429 && apiErr.retryAfterSeconds !== undefined) {
60
+ countdown.start(apiErr.retryAfterSeconds);
61
+ setWasThrottled(true);
62
+ }
54
63
  setError(
55
64
  t(`errors.${apiErr.code}`) +
56
65
  (apiErr.requestId
@@ -107,6 +116,11 @@ export function LoginForm({
107
116
  {error}
108
117
  </p>
109
118
  )}
119
+ {wasThrottled && !throttled && (
120
+ <p role="status" className="text-sm">
121
+ {t("pages.login.canRetry")}
122
+ </p>
123
+ )}
110
124
  <Field>
111
125
  <FieldLabel htmlFor="empNo">{t("fields.empNo")}</FieldLabel>
112
126
  <Input
@@ -134,10 +148,14 @@ export function LoginForm({
134
148
  <Field>
135
149
  <Button
136
150
  type="submit"
137
- disabled={pending}
151
+ disabled={pending || throttled}
138
152
  aria-busy={pending || undefined}
139
153
  >
140
- {pending ? t("pages.login.submitting") : t("nav.signIn")}
154
+ {pending
155
+ ? t("pages.login.submitting")
156
+ : throttled
157
+ ? t("pages.login.retryIn", { seconds: countdown.remaining })
158
+ : t("nav.signIn")}
141
159
  </Button>
142
160
  <FieldDescription className="text-center">
143
161
  {t("pages.login.noAccount")}{" "}
@@ -0,0 +1,73 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import { axe } from "vitest-axe";
3
+ import { initI18n } from "@/shared/i18n";
4
+ import { PasswordChecklist } from "./PasswordChecklist";
5
+
6
+ const statuses = () =>
7
+ Object.fromEntries(
8
+ Array.from(document.querySelectorAll("li[data-rule]")).map((li) => [
9
+ li.getAttribute("data-rule"),
10
+ li.getAttribute("data-status"),
11
+ ]),
12
+ );
13
+
14
+ /** Renders and waits until the blocklist chunk is in (COMMON_PASSWORD no longer "server"). */
15
+ async function show(password: string, empNo = "") {
16
+ const view = render(<PasswordChecklist id="rules" password={password} empNo={empNo} />);
17
+ if (password !== "")
18
+ await waitFor(() => expect(statuses().COMMON_PASSWORD).not.toBe("server"));
19
+ return view;
20
+ }
21
+
22
+ beforeEach(async () => {
23
+ await initI18n("en");
24
+ });
25
+
26
+ describe("PasswordChecklist", () => {
27
+ it("lists the 8 rules in PRD §6.4 order, neutral while empty", async () => {
28
+ await show("");
29
+ expect(Object.keys(statuses())).toEqual([
30
+ "LENGTH",
31
+ "CHARSET",
32
+ "COMPLEXITY",
33
+ "COMMON_PASSWORD",
34
+ "KEYBOARD_RUN",
35
+ "SEQUENCE",
36
+ "REPEAT",
37
+ "CONTAINS_EMP_NO",
38
+ ]);
39
+ expect(new Set(Object.values(statuses()))).toEqual(new Set(["idle"]));
40
+ });
41
+
42
+ it("marks each rule met or not met as the password changes", async () => {
43
+ const { rerender } = await show("password");
44
+ expect(statuses()).toMatchObject({
45
+ LENGTH: "unmet",
46
+ CHARSET: "met",
47
+ COMPLEXITY: "unmet",
48
+ COMMON_PASSWORD: "unmet",
49
+ KEYBOARD_RUN: "met",
50
+ });
51
+ rerender(<PasswordChecklist id="rules" password="Correct-Horse-1" empNo="089290" />);
52
+ expect(new Set(Object.values(statuses()))).toEqual(new Set(["met"]));
53
+ });
54
+
55
+ it("checks the employee ID only when it is well-formed (as the server)", async () => {
56
+ const { rerender } = await show("Correct-089290-Ab", "089290");
57
+ expect(statuses().CONTAINS_EMP_NO).toBe("unmet");
58
+ rerender(<PasswordChecklist id="rules" password="Correct-089290-Ab" empNo="08929" />);
59
+ expect(statuses().CONTAINS_EMP_NO).toBe("met");
60
+ });
61
+
62
+ it("says the state in text, not colour alone (WCAG 1.4.1), and has no axe violations", async () => {
63
+ const { container } = await show("password");
64
+ expect(screen.getByText(/be 12–64 characters long/).textContent).toContain("(not met)");
65
+ expect(screen.getByText(/use only printable ASCII/).textContent).toContain("(met)");
66
+ expect((await axe(container)).violations).toEqual([]);
67
+ });
68
+
69
+ it("is not a live region (no announcement on every keystroke)", async () => {
70
+ const { container } = await show("x");
71
+ expect(container.querySelector("[aria-live],[role=status],[role=alert]")).toBeNull();
72
+ });
73
+ });