@johnnyboy11234/esg-idea-contest 0.2.1 → 0.2.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.
@@ -0,0 +1,209 @@
1
+ import { http, HttpResponse } from 'msw';
2
+ import { problem } from './mocks/problem';
3
+ import { server } from './mocks/server';
4
+ import { ApiError } from './problem';
5
+ import { clearCsrfToken } from './csrf';
6
+ import { apiFetch, setUnauthenticatedHandler } from './http';
7
+
8
+ const API = 'http://localhost:3000/api/v1';
9
+
10
+ /** Counts GET /auth/csrf and hands out t1, t2, … */
11
+ function csrfEndpoint() {
12
+ const state = { calls: 0 };
13
+ server.use(
14
+ http.get(`${API}/auth/csrf`, () => {
15
+ state.calls += 1;
16
+ return HttpResponse.json({ token: `t${state.calls}` });
17
+ }),
18
+ );
19
+ return state;
20
+ }
21
+
22
+ beforeEach(() => {
23
+ clearCsrfToken();
24
+ setUnauthenticatedHandler(null);
25
+ });
26
+
27
+ describe('CSRF token', () => {
28
+ it('is fetched lazily, cached, and sent on writes only', async () => {
29
+ const csrf = csrfEndpoint();
30
+ const seen: (string | null)[] = [];
31
+ server.use(
32
+ http.get(`${API}/deadline`, ({ request }) => {
33
+ seen.push(request.headers.get('X-CSRF-Token'));
34
+ return HttpResponse.json({});
35
+ }),
36
+ http.post(`${API}/join-requests`, ({ request }) => {
37
+ seen.push(request.headers.get('X-CSRF-Token'));
38
+ return HttpResponse.json({}, { status: 201 });
39
+ }),
40
+ http.delete(`${API}/ideas/x`, ({ request }) => {
41
+ seen.push(request.headers.get('X-CSRF-Token'));
42
+ return new HttpResponse(null, { status: 204 });
43
+ }),
44
+ );
45
+ await apiFetch('/deadline');
46
+ expect(csrf.calls).toBe(0);
47
+ await apiFetch('/join-requests', { method: 'POST', body: { a: 1 } });
48
+ await apiFetch('/ideas/x', { method: 'DELETE' });
49
+ expect(csrf.calls).toBe(1);
50
+ expect(seen).toEqual([null, 't1', 't1']);
51
+ });
52
+
53
+ it('is shared by concurrent first writes', async () => {
54
+ const csrf = csrfEndpoint();
55
+ server.use(http.post(`${API}/join-requests`, () => HttpResponse.json({}, { status: 201 })));
56
+ await Promise.all([1, 2, 3].map(() => apiFetch('/join-requests', { method: 'POST', body: {} })));
57
+ expect(csrf.calls).toBe(1);
58
+ });
59
+
60
+ it('sends JSON bodies with a JSON content type', async () => {
61
+ csrfEndpoint();
62
+ let ct: string | null = null;
63
+ let body: unknown;
64
+ server.use(
65
+ http.post(`${API}/auth/register`, async ({ request }) => {
66
+ ct = request.headers.get('Content-Type');
67
+ body = await request.json();
68
+ return HttpResponse.json({}, { status: 201 });
69
+ }),
70
+ );
71
+ await apiFetch('/auth/register', { method: 'POST', body: { empNo: '1' } });
72
+ expect(ct).toBe('application/json');
73
+ expect(body).toEqual({ empNo: '1' });
74
+ });
75
+
76
+ it.each(['/auth/login', '/auth/logout'])('is refetched after a successful %s', async (path) => {
77
+ const csrf = csrfEndpoint();
78
+ const seen: string[] = [];
79
+ server.use(
80
+ http.post(`${API}${path}`, ({ request }) => {
81
+ seen.push(request.headers.get('X-CSRF-Token') ?? '');
82
+ return path === '/auth/login' ? HttpResponse.json({}) : new HttpResponse(null, { status: 204 });
83
+ }),
84
+ );
85
+ await apiFetch(path, { method: 'POST', body: {} });
86
+ await apiFetch(path, { method: 'POST', body: {} });
87
+ expect(csrf.calls).toBe(2);
88
+ expect(seen).toEqual(['t1', 't2']);
89
+ });
90
+
91
+ it('is kept after a failed login', async () => {
92
+ const csrf = csrfEndpoint();
93
+ server.use(http.post(`${API}/auth/login`, () => problem(401, 'INVALID_CREDENTIALS')));
94
+ const unauth = vi.fn();
95
+ setUnauthenticatedHandler(unauth);
96
+ await expect(apiFetch('/auth/login', { method: 'POST', body: {} })).rejects.toMatchObject({ code: 'INVALID_CREDENTIALS' });
97
+ await expect(apiFetch('/auth/login', { method: 'POST', body: {} })).rejects.toBeInstanceOf(ApiError);
98
+ expect(csrf.calls).toBe(1);
99
+ expect(unauth).not.toHaveBeenCalled();
100
+ });
101
+ });
102
+
103
+ describe('CSRF_INVALID', () => {
104
+ it('refetches and retries once, resending the same body', async () => {
105
+ const csrf = csrfEndpoint();
106
+ const attempts: { token: string | null; body: unknown }[] = [];
107
+ server.use(
108
+ http.post(`${API}/join-requests`, async ({ request }) => {
109
+ attempts.push({ token: request.headers.get('X-CSRF-Token'), body: await request.json() });
110
+ return attempts.length === 1 ? problem(403, 'CSRF_INVALID') : HttpResponse.json({}, { status: 201 });
111
+ }),
112
+ );
113
+ const res = await apiFetch('/join-requests', { method: 'POST', body: { ideaId: 'i' } });
114
+ expect(res.status).toBe(201);
115
+ expect(csrf.calls).toBe(2);
116
+ expect(attempts).toEqual([
117
+ { token: 't1', body: { ideaId: 'i' } },
118
+ { token: 't2', body: { ideaId: 'i' } },
119
+ ]);
120
+ });
121
+
122
+ it('does not retry a second time', async () => {
123
+ const csrf = csrfEndpoint();
124
+ let posts = 0;
125
+ server.use(
126
+ http.post(`${API}/join-requests`, () => {
127
+ posts += 1;
128
+ return problem(403, 'CSRF_INVALID');
129
+ }),
130
+ );
131
+ await expect(apiFetch('/join-requests', { method: 'POST', body: {} })).rejects.toMatchObject({ status: 403, code: 'CSRF_INVALID' });
132
+ expect(posts).toBe(2);
133
+ expect(csrf.calls).toBe(2);
134
+ });
135
+
136
+ it('does not retry other 403s', async () => {
137
+ const csrf = csrfEndpoint();
138
+ let posts = 0;
139
+ server.use(
140
+ http.post(`${API}/join-requests`, () => {
141
+ posts += 1;
142
+ return problem(403, 'SUBMISSION_CLOSED');
143
+ }),
144
+ );
145
+ await expect(apiFetch('/join-requests', { method: 'POST', body: {} })).rejects.toMatchObject({ code: 'SUBMISSION_CLOSED' });
146
+ expect(posts).toBe(1);
147
+ expect(csrf.calls).toBe(1);
148
+ });
149
+
150
+ it('resends a multipart body on retry', async () => {
151
+ csrfEndpoint();
152
+ const names: (string | null)[] = [];
153
+ server.use(
154
+ http.post(`${API}/ideas`, async ({ request }) => {
155
+ const f = (await request.formData()).get('title');
156
+ names.push(f as string);
157
+ return names.length === 1 ? problem(403, 'CSRF_INVALID') : HttpResponse.json({}, { status: 201 });
158
+ }),
159
+ );
160
+ const fd = new FormData();
161
+ fd.set('title', 'T');
162
+ await apiFetch('/ideas', { method: 'POST', body: fd });
163
+ expect(names).toEqual(['T', 'T']);
164
+ });
165
+
166
+ it('a failed token fetch surfaces its error and caches nothing', async () => {
167
+ server.use(http.get(`${API}/auth/csrf`, () => problem(429, 'RATE_LIMITED')));
168
+ await expect(apiFetch('/join-requests', { method: 'POST', body: {} })).rejects.toMatchObject({ status: 429 });
169
+ const csrf = csrfEndpoint();
170
+ server.use(http.post(`${API}/join-requests`, () => HttpResponse.json({}, { status: 201 })));
171
+ await apiFetch('/join-requests', { method: 'POST', body: {} });
172
+ expect(csrf.calls).toBe(1);
173
+ });
174
+ });
175
+
176
+ describe('401', () => {
177
+ it('clears the token and calls the handler', async () => {
178
+ const csrf = csrfEndpoint();
179
+ const unauth = vi.fn();
180
+ setUnauthenticatedHandler(unauth);
181
+ let n = 0;
182
+ server.use(
183
+ http.post(`${API}/join-requests`, () => {
184
+ n += 1;
185
+ return n === 1 ? problem(401, 'UNAUTHENTICATED') : HttpResponse.json({}, { status: 201 });
186
+ }),
187
+ );
188
+ await expect(apiFetch('/join-requests', { method: 'POST', body: {} })).rejects.toMatchObject({ status: 401, code: 'UNAUTHENTICATED' });
189
+ expect(unauth).toHaveBeenCalledTimes(1);
190
+ await apiFetch('/join-requests', { method: 'POST', body: {} });
191
+ expect(csrf.calls).toBe(2);
192
+ });
193
+
194
+ it('is an answer, not a redirect, when allowUnauthenticated is set', async () => {
195
+ const unauth = vi.fn();
196
+ setUnauthenticatedHandler(unauth);
197
+ server.use(http.get(`${API}/auth/me`, () => problem(401, 'UNAUTHENTICATED')));
198
+ await expect(apiFetch('/auth/me', { allowUnauthenticated: true })).rejects.toMatchObject({ status: 401 });
199
+ expect(unauth).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it('fires for reads too', async () => {
203
+ const unauth = vi.fn();
204
+ setUnauthenticatedHandler(unauth);
205
+ server.use(http.get(`${API}/ideas`, () => problem(401, 'UNAUTHENTICATED')));
206
+ await expect(apiFetch('/ideas')).rejects.toBeInstanceOf(ApiError);
207
+ expect(unauth).toHaveBeenCalledTimes(1);
208
+ });
209
+ });
@@ -0,0 +1,91 @@
1
+ import { API_BASE, clearCsrfToken, getCsrfToken, refreshCsrfToken } from './csrf';
2
+ import { parseApiError, type ApiError } from './problem';
3
+
4
+ const SAFE = new Set(['GET', 'HEAD', 'OPTIONS']);
5
+
6
+ /** Endpoints after which the session binding of the CSRF token changes (PRD §6.5). */
7
+ const SESSION_CHANGING = new Set(['/auth/login', '/auth/logout']);
8
+
9
+ type Unauthenticated = () => void;
10
+ let onUnauthenticated: Unauthenticated | null = null;
11
+
12
+ /** The app registers what a 401 does (clear `me`, go to /login?next=). Returns an unregister function. */
13
+ export function setUnauthenticatedHandler(fn: Unauthenticated | null): () => void {
14
+ onUnauthenticated = fn;
15
+ return () => {
16
+ if (onUnauthenticated === fn) onUnauthenticated = null;
17
+ };
18
+ }
19
+
20
+ export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
21
+ /** Plain objects are sent as JSON; strings, FormData and Blobs are sent as given. */
22
+ body?: BodyInit | Record<string, unknown> | null;
23
+ /** A 401 is an answer here (e.g. `GET /auth/me` means "visitor"), not a session end. */
24
+ allowUnauthenticated?: boolean;
25
+ }
26
+
27
+ function prepare(init: ApiRequestInit): { headers: Headers; body: BodyInit | null | undefined } {
28
+ const headers = new Headers(init.headers);
29
+ if (!headers.has('Accept')) headers.set('Accept', 'application/json');
30
+ let body = init.body as BodyInit | null | undefined;
31
+ if (body !== null && body !== undefined && typeof body === 'object' && !isNativeBody(body)) {
32
+ body = JSON.stringify(body);
33
+ if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
34
+ }
35
+ return { headers, body };
36
+ }
37
+
38
+ function isNativeBody(b: object): boolean {
39
+ return (
40
+ b instanceof FormData ||
41
+ b instanceof Blob ||
42
+ b instanceof URLSearchParams ||
43
+ b instanceof ArrayBuffer ||
44
+ ArrayBuffer.isView(b) ||
45
+ b instanceof ReadableStream
46
+ );
47
+ }
48
+
49
+ /**
50
+ * fetch for `/api/v1`: `path` is relative to it (`/auth/me`). Resolves with the
51
+ * Response on 2xx and throws ApiError otherwise.
52
+ *
53
+ * - Writes send `X-CSRF-Token` (lazily fetched, cached in memory).
54
+ * - `CSRF_INVALID` refetches the token and retries the write once. The request
55
+ * was rejected before processing, so the retry is safe (Frontend.md §5.2).
56
+ * A request whose body is a stream cannot be replayed and is not retried.
57
+ * - A successful login or logout clears the token (the binding changed).
58
+ * - A 401 clears the token and calls the registered handler, except for
59
+ * `INVALID_CREDENTIALS` (a wrong password is not a session end).
60
+ */
61
+ export async function apiFetch(path: string, init: ApiRequestInit = {}): Promise<Response> {
62
+ const method = (init.method ?? 'GET').toUpperCase();
63
+ const write = !SAFE.has(method);
64
+ const { headers, body } = prepare(init);
65
+ const send = (csrf: string | null) => {
66
+ const h = new Headers(headers);
67
+ if (csrf !== null) h.set('X-CSRF-Token', csrf);
68
+ return fetch(`${API_BASE()}${path}`, { ...init, method, headers: h, body, credentials: 'same-origin' });
69
+ };
70
+
71
+ let res = await send(write ? await getCsrfToken() : null);
72
+ if (write && !res.ok && !(body instanceof ReadableStream) && (await errorCode(res)) === 'CSRF_INVALID') {
73
+ res = await send(await refreshCsrfToken());
74
+ }
75
+
76
+ if (res.ok) {
77
+ if (write && SESSION_CHANGING.has(path)) clearCsrfToken();
78
+ return res;
79
+ }
80
+ const err = await parseApiError(res);
81
+ if (err.status === 401 && err.code !== 'INVALID_CREDENTIALS' && !init.allowUnauthenticated) {
82
+ clearCsrfToken();
83
+ onUnauthenticated?.();
84
+ }
85
+ throw err;
86
+ }
87
+
88
+ /** Reads the error code without consuming the response. */
89
+ async function errorCode(res: Response): Promise<ApiError['code']> {
90
+ return (await parseApiError(res.clone())).code;
91
+ }
@@ -1,3 +1,6 @@
1
1
  export { ApiError, parseApiError } from './problem';
2
2
  export type { ErrorCode, FieldError } from './problem';
3
+ export { apiFetch, setUnauthenticatedHandler } from './http';
4
+ export type { ApiRequestInit } from './http';
5
+ export { clearCsrfToken, getCsrfToken } from './csrf';
3
6
  export type { paths, components } from './types.generated';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnnyboy11234/esg-idea-contest",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "ESG Idea Contest registration system: Go backend, React frontend and design documents (source distribution).",
5
5
  "license": "MIT",
6
6
  "author": "Brian Huang",