@cosmicdrift/kumiko-bundled-features 0.159.1 → 0.160.0
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.
- package/package.json +6 -6
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +24 -0
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/config/__tests__/app-overrides.test.ts +11 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
- package/src/config/__tests__/cascade.integration.test.ts +39 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +20 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +180 -0
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.160.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -117,11 +117,11 @@
|
|
|
117
117
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
118
118
|
},
|
|
119
119
|
"dependencies": {
|
|
120
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
121
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
122
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
124
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
120
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.160.0",
|
|
121
|
+
"@cosmicdrift/kumiko-framework": "0.160.0",
|
|
122
|
+
"@cosmicdrift/kumiko-headless": "0.160.0",
|
|
123
|
+
"@cosmicdrift/kumiko-renderer": "0.160.0",
|
|
124
|
+
"@cosmicdrift/kumiko-renderer-web": "0.160.0",
|
|
125
125
|
"@mollie/api-client": "^4.5.0",
|
|
126
126
|
"imapflow": "^1.3.3",
|
|
127
127
|
"mailparser": "^3.9.8",
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { CSRF_HEADER_NAME } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
3
|
+
import {
|
|
4
|
+
confirmAccountUnlock,
|
|
5
|
+
confirmSignup,
|
|
6
|
+
csrfHeader,
|
|
7
|
+
fetchCurrentUser,
|
|
8
|
+
fetchTenants,
|
|
9
|
+
login,
|
|
10
|
+
logout,
|
|
11
|
+
requestAccountUnlock,
|
|
12
|
+
requestEmailVerification,
|
|
13
|
+
requestPasswordReset,
|
|
14
|
+
requestSignup,
|
|
15
|
+
resetPassword,
|
|
16
|
+
switchTenant,
|
|
17
|
+
verifyEmail,
|
|
18
|
+
} from "../auth-client";
|
|
19
|
+
|
|
20
|
+
const CSRF_TOKEN = "csrf-test-token";
|
|
21
|
+
|
|
22
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
23
|
+
return new Response(JSON.stringify(body), { status });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function withCsrfDocument(): void {
|
|
27
|
+
(globalThis as { document?: { cookie: string } }).document = {
|
|
28
|
+
cookie: `kumiko_csrf=${CSRF_TOKEN}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function withoutCsrfDocument(): void {
|
|
33
|
+
delete (globalThis as { document?: { cookie: string } }).document;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
globalThis.fetch = mock(
|
|
38
|
+
async () => new Response(null, { status: 200 }),
|
|
39
|
+
) as unknown as typeof fetch;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
withoutCsrfDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("csrfHeader", () => {
|
|
47
|
+
test("returns CSRF header when kumiko_csrf cookie is present", () => {
|
|
48
|
+
withCsrfDocument();
|
|
49
|
+
expect(csrfHeader()).toEqual({ [CSRF_HEADER_NAME]: CSRF_TOKEN });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("returns empty object when no CSRF cookie", () => {
|
|
53
|
+
withoutCsrfDocument();
|
|
54
|
+
expect(csrfHeader()).toEqual({});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("login", () => {
|
|
59
|
+
test("HTTP 429 → kind:failure reason:rate_limited", async () => {
|
|
60
|
+
globalThis.fetch = mock(
|
|
61
|
+
async () => new Response(null, { status: 429 }),
|
|
62
|
+
) as unknown as typeof fetch;
|
|
63
|
+
|
|
64
|
+
const res = await login({ email: "a@b.c", password: "secret" });
|
|
65
|
+
|
|
66
|
+
expect(res).toEqual({ kind: "failure", error: { reason: "rate_limited" } });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("success → kind:success with token and user", async () => {
|
|
70
|
+
globalThis.fetch = mock(async () =>
|
|
71
|
+
jsonResponse({
|
|
72
|
+
isSuccess: true,
|
|
73
|
+
token: "jwt-1",
|
|
74
|
+
user: { id: "u1", tenantId: "t1", roles: ["Admin"] },
|
|
75
|
+
}),
|
|
76
|
+
) as unknown as typeof fetch;
|
|
77
|
+
|
|
78
|
+
const res = await login({ email: "a@b.c", password: "secret" });
|
|
79
|
+
|
|
80
|
+
expect(res).toEqual({
|
|
81
|
+
kind: "success",
|
|
82
|
+
data: { token: "jwt-1", user: { id: "u1", tenantId: "t1", roles: ["Admin"] } },
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("MFA enrolled → kind:mfa-challenge", async () => {
|
|
87
|
+
globalThis.fetch = mock(async () =>
|
|
88
|
+
jsonResponse({ isSuccess: true, mfaRequired: true, challengeToken: "challenge-abc" }),
|
|
89
|
+
) as unknown as typeof fetch;
|
|
90
|
+
|
|
91
|
+
const res = await login({ email: "a@b.c", password: "secret" });
|
|
92
|
+
|
|
93
|
+
expect(res).toEqual({ kind: "mfa-challenge", challengeToken: "challenge-abc" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("MFA enforcement blocks unenrolled user → kind:mfa-setup-required", async () => {
|
|
97
|
+
globalThis.fetch = mock(async () =>
|
|
98
|
+
jsonResponse({ isSuccess: true, mfaSetupRequired: true }),
|
|
99
|
+
) as unknown as typeof fetch;
|
|
100
|
+
|
|
101
|
+
const res = await login({ email: "a@b.c", password: "secret" });
|
|
102
|
+
|
|
103
|
+
expect(res).toEqual({ kind: "mfa-setup-required" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("string error → kind:failure with reason from string", async () => {
|
|
107
|
+
globalThis.fetch = mock(async () =>
|
|
108
|
+
jsonResponse({ isSuccess: false, error: "invalid_credentials" }),
|
|
109
|
+
) as unknown as typeof fetch;
|
|
110
|
+
|
|
111
|
+
const res = await login({ email: "a@b.c", password: "wrong" });
|
|
112
|
+
|
|
113
|
+
expect(res).toEqual({ kind: "failure", error: { reason: "invalid_credentials" } });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("structured error → reason, message, retryAfterSeconds from details", async () => {
|
|
117
|
+
globalThis.fetch = mock(async () =>
|
|
118
|
+
jsonResponse({
|
|
119
|
+
isSuccess: false,
|
|
120
|
+
error: {
|
|
121
|
+
code: "login_failed",
|
|
122
|
+
message: "Account locked",
|
|
123
|
+
details: { reason: "account_locked", retryAfterSeconds: 300 },
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
) as unknown as typeof fetch;
|
|
127
|
+
|
|
128
|
+
const res = await login({ email: "a@b.c", password: "secret" });
|
|
129
|
+
|
|
130
|
+
expect(res).toEqual({
|
|
131
|
+
kind: "failure",
|
|
132
|
+
error: { reason: "account_locked", message: "Account locked", retryAfterSeconds: 300 },
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("structured error without details.reason → falls back to error.code", async () => {
|
|
137
|
+
globalThis.fetch = mock(async () =>
|
|
138
|
+
jsonResponse({ isSuccess: false, error: { code: "no_membership" } }),
|
|
139
|
+
) as unknown as typeof fetch;
|
|
140
|
+
|
|
141
|
+
const res = await login({ email: "a@b.c", password: "secret" });
|
|
142
|
+
|
|
143
|
+
expect(res).toEqual({ kind: "failure", error: { reason: "no_membership" } });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("no error field → default reason login_failed", async () => {
|
|
147
|
+
globalThis.fetch = mock(async () => jsonResponse({})) as unknown as typeof fetch;
|
|
148
|
+
|
|
149
|
+
const res = await login({ email: "a@b.c", password: "secret" });
|
|
150
|
+
|
|
151
|
+
expect(res).toEqual({ kind: "failure", error: { reason: "login_failed" } });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("isSuccess true but missing token/user → login_failed fallback", async () => {
|
|
155
|
+
globalThis.fetch = mock(async () =>
|
|
156
|
+
jsonResponse({ isSuccess: true }),
|
|
157
|
+
) as unknown as typeof fetch;
|
|
158
|
+
|
|
159
|
+
const res = await login({ email: "a@b.c", password: "secret" });
|
|
160
|
+
|
|
161
|
+
expect(res).toEqual({ kind: "failure", error: { reason: "login_failed" } });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("malformed JSON body → login_failed fallback", async () => {
|
|
165
|
+
globalThis.fetch = mock(
|
|
166
|
+
async () => new Response("not json", { status: 200 }),
|
|
167
|
+
) as unknown as typeof fetch;
|
|
168
|
+
|
|
169
|
+
const res = await login({ email: "a@b.c", password: "secret" });
|
|
170
|
+
|
|
171
|
+
expect(res).toEqual({ kind: "failure", error: { reason: "login_failed" } });
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("logout", () => {
|
|
176
|
+
test("POSTs to /api/auth/logout with CSRF header when cookie present", async () => {
|
|
177
|
+
withCsrfDocument();
|
|
178
|
+
const fetchMock = mock(async () => new Response(null, { status: 200 }));
|
|
179
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
180
|
+
|
|
181
|
+
await logout();
|
|
182
|
+
|
|
183
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
184
|
+
"/api/auth/logout",
|
|
185
|
+
expect.objectContaining({
|
|
186
|
+
method: "POST",
|
|
187
|
+
credentials: "same-origin",
|
|
188
|
+
headers: expect.objectContaining({
|
|
189
|
+
"Content-Type": "application/json",
|
|
190
|
+
[CSRF_HEADER_NAME]: CSRF_TOKEN,
|
|
191
|
+
}),
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
type TokenEndpoint = {
|
|
198
|
+
name: string;
|
|
199
|
+
fn: (
|
|
200
|
+
arg: string,
|
|
201
|
+
) => Promise<{ ok: true } | { ok: false; error: { reason: string; retryAfterSeconds?: number } }>;
|
|
202
|
+
url: string;
|
|
203
|
+
bodyKey: string;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const tokenEndpoints: TokenEndpoint[] = [
|
|
207
|
+
{
|
|
208
|
+
name: "requestPasswordReset",
|
|
209
|
+
fn: requestPasswordReset,
|
|
210
|
+
url: "/api/auth/request-password-reset",
|
|
211
|
+
bodyKey: "email",
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "resetPassword",
|
|
215
|
+
fn: (email) => resetPassword("tok", email),
|
|
216
|
+
url: "/api/auth/reset-password",
|
|
217
|
+
bodyKey: "newPassword",
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "requestEmailVerification",
|
|
221
|
+
fn: requestEmailVerification,
|
|
222
|
+
url: "/api/auth/request-email-verification",
|
|
223
|
+
bodyKey: "email",
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: "verifyEmail",
|
|
227
|
+
fn: verifyEmail,
|
|
228
|
+
url: "/api/auth/verify-email",
|
|
229
|
+
bodyKey: "token",
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: "requestAccountUnlock",
|
|
233
|
+
fn: requestAccountUnlock,
|
|
234
|
+
url: "/api/auth/request-account-unlock",
|
|
235
|
+
bodyKey: "email",
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "confirmAccountUnlock",
|
|
239
|
+
fn: confirmAccountUnlock,
|
|
240
|
+
url: "/api/auth/confirm-account-unlock",
|
|
241
|
+
bodyKey: "token",
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: "requestSignup",
|
|
245
|
+
fn: requestSignup,
|
|
246
|
+
url: "/api/auth/signup-request",
|
|
247
|
+
bodyKey: "email",
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
for (const { name, fn, url, bodyKey } of tokenEndpoints) {
|
|
252
|
+
describe(name, () => {
|
|
253
|
+
test("ok → { ok: true }", async () => {
|
|
254
|
+
withCsrfDocument();
|
|
255
|
+
const fetchMock = mock(async () => new Response(null, { status: 200 }));
|
|
256
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
257
|
+
|
|
258
|
+
const res = await fn(bodyKey === "email" ? "user@test.com" : "token-1");
|
|
259
|
+
|
|
260
|
+
expect(res).toEqual({ ok: true });
|
|
261
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
262
|
+
url,
|
|
263
|
+
expect.objectContaining({
|
|
264
|
+
method: "POST",
|
|
265
|
+
credentials: "same-origin",
|
|
266
|
+
headers: expect.objectContaining({ [CSRF_HEADER_NAME]: CSRF_TOKEN }),
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("429 → rate_limited with retryAfterSeconds", async () => {
|
|
272
|
+
globalThis.fetch = mock(async () =>
|
|
273
|
+
jsonResponse({ error: { details: { retryAfterSeconds: 120 } } }, 429),
|
|
274
|
+
) as unknown as typeof fetch;
|
|
275
|
+
|
|
276
|
+
const res = await fn(bodyKey === "email" ? "user@test.com" : "token-1");
|
|
277
|
+
|
|
278
|
+
expect(res).toEqual({ ok: false, error: { reason: "rate_limited", retryAfterSeconds: 120 } });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("other failure → reason from details or code", async () => {
|
|
282
|
+
globalThis.fetch = mock(async () =>
|
|
283
|
+
jsonResponse(
|
|
284
|
+
{ error: { code: "validation_failed", details: { reason: "invalid_token" } } },
|
|
285
|
+
422,
|
|
286
|
+
),
|
|
287
|
+
) as unknown as typeof fetch;
|
|
288
|
+
|
|
289
|
+
const res = await fn(bodyKey === "email" ? "user@test.com" : "token-1");
|
|
290
|
+
|
|
291
|
+
expect(res).toEqual({ ok: false, error: { reason: "invalid_token" } });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("failure without details → unknown fallback", async () => {
|
|
295
|
+
globalThis.fetch = mock(async () => jsonResponse({}, 500)) as unknown as typeof fetch;
|
|
296
|
+
|
|
297
|
+
const res = await fn(bodyKey === "email" ? "user@test.com" : "token-1");
|
|
298
|
+
|
|
299
|
+
expect(res).toEqual({ ok: false, error: { reason: "unknown" } });
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
describe("resetPassword", () => {
|
|
305
|
+
test("sends token and newPassword in body", async () => {
|
|
306
|
+
withCsrfDocument();
|
|
307
|
+
const fetchMock = mock(async () => new Response(null, { status: 200 }));
|
|
308
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
309
|
+
|
|
310
|
+
await resetPassword("reset-tok", "newpass123");
|
|
311
|
+
|
|
312
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
313
|
+
"/api/auth/reset-password",
|
|
314
|
+
expect.objectContaining({
|
|
315
|
+
body: JSON.stringify({ token: "reset-tok", newPassword: "newpass123" }),
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("confirmSignup", () => {
|
|
322
|
+
test("ok → returns parsed data", async () => {
|
|
323
|
+
withCsrfDocument();
|
|
324
|
+
const data = {
|
|
325
|
+
user: { id: "u1", tenantId: "t1", roles: ["Member"] },
|
|
326
|
+
tenantKey: "acme",
|
|
327
|
+
};
|
|
328
|
+
globalThis.fetch = mock(async () => jsonResponse(data)) as unknown as typeof fetch;
|
|
329
|
+
|
|
330
|
+
const res = await confirmSignup("signup-tok", "password123");
|
|
331
|
+
|
|
332
|
+
expect(res).toEqual({ ok: true, data });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("failure → parseTokenFailure shape", async () => {
|
|
336
|
+
globalThis.fetch = mock(async () =>
|
|
337
|
+
jsonResponse({ error: { code: "invalid_signup_token" } }, 422),
|
|
338
|
+
) as unknown as typeof fetch;
|
|
339
|
+
|
|
340
|
+
const res = await confirmSignup("bad-tok", "password123");
|
|
341
|
+
|
|
342
|
+
expect(res).toEqual({ ok: false, error: { reason: "invalid_signup_token" } });
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("fetchTenants", () => {
|
|
347
|
+
test("401 → null", async () => {
|
|
348
|
+
globalThis.fetch = mock(
|
|
349
|
+
async () => new Response(null, { status: 401 }),
|
|
350
|
+
) as unknown as typeof fetch;
|
|
351
|
+
|
|
352
|
+
const res = await fetchTenants();
|
|
353
|
+
|
|
354
|
+
expect(res).toBeNull();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("ok → returns tenants and activeTenantId", async () => {
|
|
358
|
+
const body = {
|
|
359
|
+
tenants: [{ tenantId: "t1", roles: ["Admin"], name: "Acme" }],
|
|
360
|
+
activeTenantId: "t1",
|
|
361
|
+
};
|
|
362
|
+
globalThis.fetch = mock(async () => jsonResponse(body)) as unknown as typeof fetch;
|
|
363
|
+
|
|
364
|
+
const res = await fetchTenants();
|
|
365
|
+
|
|
366
|
+
expect(res).toEqual(body);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("non-ok status → throws", async () => {
|
|
370
|
+
globalThis.fetch = mock(
|
|
371
|
+
async () => new Response(null, { status: 500 }),
|
|
372
|
+
) as unknown as typeof fetch;
|
|
373
|
+
|
|
374
|
+
await expect(fetchTenants()).rejects.toThrow("auth/tenants failed: 500");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("switchTenant", () => {
|
|
379
|
+
test("ok → resolves without error", async () => {
|
|
380
|
+
withCsrfDocument();
|
|
381
|
+
const fetchMock = mock(async () => new Response(null, { status: 200 }));
|
|
382
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
383
|
+
|
|
384
|
+
await switchTenant("tenant-2");
|
|
385
|
+
|
|
386
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
387
|
+
"/api/auth/switch-tenant",
|
|
388
|
+
expect.objectContaining({
|
|
389
|
+
body: JSON.stringify({ tenantId: "tenant-2" }),
|
|
390
|
+
headers: expect.objectContaining({ [CSRF_HEADER_NAME]: CSRF_TOKEN }),
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("!ok → throws with status and body", async () => {
|
|
396
|
+
globalThis.fetch = mock(async () =>
|
|
397
|
+
jsonResponse({ error: { code: "not_a_member" } }, 403),
|
|
398
|
+
) as unknown as typeof fetch;
|
|
399
|
+
|
|
400
|
+
await expect(switchTenant("tenant-x")).rejects.toThrow(
|
|
401
|
+
'switch-tenant failed: 403 {"error":{"code":"not_a_member"}}',
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("!ok with unparseable body → throws with empty object", async () => {
|
|
406
|
+
globalThis.fetch = mock(
|
|
407
|
+
async () => new Response("not json", { status: 400 }),
|
|
408
|
+
) as unknown as typeof fetch;
|
|
409
|
+
|
|
410
|
+
await expect(switchTenant("tenant-x")).rejects.toThrow("switch-tenant failed: 400 {}");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe("fetchCurrentUser", () => {
|
|
415
|
+
test("401 → null", async () => {
|
|
416
|
+
globalThis.fetch = mock(
|
|
417
|
+
async () => new Response(null, { status: 401 }),
|
|
418
|
+
) as unknown as typeof fetch;
|
|
419
|
+
|
|
420
|
+
const res = await fetchCurrentUser();
|
|
421
|
+
|
|
422
|
+
expect(res).toBeNull();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("missing data field → null", async () => {
|
|
426
|
+
globalThis.fetch = mock(async () => jsonResponse({})) as unknown as typeof fetch;
|
|
427
|
+
|
|
428
|
+
const res = await fetchCurrentUser();
|
|
429
|
+
|
|
430
|
+
expect(res).toBeNull();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("full profile with locale and roles JSON", async () => {
|
|
434
|
+
withCsrfDocument();
|
|
435
|
+
globalThis.fetch = mock(async () =>
|
|
436
|
+
jsonResponse({
|
|
437
|
+
data: {
|
|
438
|
+
id: "u1",
|
|
439
|
+
email: "user@test.com",
|
|
440
|
+
displayName: "Test User",
|
|
441
|
+
locale: "de",
|
|
442
|
+
roles: '["SystemAdmin","Support"]',
|
|
443
|
+
},
|
|
444
|
+
}),
|
|
445
|
+
) as unknown as typeof fetch;
|
|
446
|
+
|
|
447
|
+
const res = await fetchCurrentUser();
|
|
448
|
+
|
|
449
|
+
expect(res).toEqual({
|
|
450
|
+
id: "u1",
|
|
451
|
+
email: "user@test.com",
|
|
452
|
+
displayName: "Test User",
|
|
453
|
+
locale: "de",
|
|
454
|
+
globalRoles: ["SystemAdmin", "Support"],
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("undefined roles → empty globalRoles", async () => {
|
|
459
|
+
globalThis.fetch = mock(async () =>
|
|
460
|
+
jsonResponse({
|
|
461
|
+
data: { id: "u1", email: "a@b.c", displayName: "User" },
|
|
462
|
+
}),
|
|
463
|
+
) as unknown as typeof fetch;
|
|
464
|
+
|
|
465
|
+
const res = await fetchCurrentUser();
|
|
466
|
+
|
|
467
|
+
expect(res?.globalRoles).toEqual([]);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("empty roles string → empty globalRoles", async () => {
|
|
471
|
+
globalThis.fetch = mock(async () =>
|
|
472
|
+
jsonResponse({
|
|
473
|
+
data: { id: "u1", email: "a@b.c", displayName: "User", roles: "" },
|
|
474
|
+
}),
|
|
475
|
+
) as unknown as typeof fetch;
|
|
476
|
+
|
|
477
|
+
const res = await fetchCurrentUser();
|
|
478
|
+
|
|
479
|
+
expect(res?.globalRoles).toEqual([]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("invalid JSON roles → empty globalRoles", async () => {
|
|
483
|
+
globalThis.fetch = mock(async () =>
|
|
484
|
+
jsonResponse({
|
|
485
|
+
data: { id: "u1", email: "a@b.c", displayName: "User", roles: "not-json" },
|
|
486
|
+
}),
|
|
487
|
+
) as unknown as typeof fetch;
|
|
488
|
+
|
|
489
|
+
const res = await fetchCurrentUser();
|
|
490
|
+
|
|
491
|
+
expect(res?.globalRoles).toEqual([]);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("non-array JSON roles → empty globalRoles", async () => {
|
|
495
|
+
globalThis.fetch = mock(async () =>
|
|
496
|
+
jsonResponse({
|
|
497
|
+
data: { id: "u1", email: "a@b.c", displayName: "User", roles: '{"role":"Admin"}' },
|
|
498
|
+
}),
|
|
499
|
+
) as unknown as typeof fetch;
|
|
500
|
+
|
|
501
|
+
const res = await fetchCurrentUser();
|
|
502
|
+
|
|
503
|
+
expect(res?.globalRoles).toEqual([]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("mixed-type array roles → empty globalRoles", async () => {
|
|
507
|
+
globalThis.fetch = mock(async () =>
|
|
508
|
+
jsonResponse({
|
|
509
|
+
data: { id: "u1", email: "a@b.c", displayName: "User", roles: '["Admin",1,true]' },
|
|
510
|
+
}),
|
|
511
|
+
) as unknown as typeof fetch;
|
|
512
|
+
|
|
513
|
+
const res = await fetchCurrentUser();
|
|
514
|
+
|
|
515
|
+
expect(res?.globalRoles).toEqual([]);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("non-ok status → throws", async () => {
|
|
519
|
+
globalThis.fetch = mock(
|
|
520
|
+
async () => new Response(null, { status: 500 }),
|
|
521
|
+
) as unknown as typeof fetch;
|
|
522
|
+
|
|
523
|
+
await expect(fetchCurrentUser()).rejects.toThrow("user:me failed: 500");
|
|
524
|
+
});
|
|
525
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { ConfirmAccountUnlockScreen } from "../confirm-account-unlock-screen";
|
|
4
|
+
import { renderWithProviders } from "./test-utils";
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
globalThis.fetch = mock(
|
|
8
|
+
async () => new Response(null, { status: 200 }),
|
|
9
|
+
) as unknown as typeof fetch;
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {});
|
|
12
|
+
|
|
13
|
+
describe("ConfirmAccountUnlockScreen", () => {
|
|
14
|
+
test("ohne Token → missing-token page", () => {
|
|
15
|
+
renderWithProviders(<ConfirmAccountUnlockScreen />);
|
|
16
|
+
expect(screen.getByText(/enthält keinen Token/i)).toBeTruthy();
|
|
17
|
+
expect(screen.getByRole("link", { name: "Zum Login" }).getAttribute("href")).toBe("/login");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("mit Token + 200 → success after auto-submit", async () => {
|
|
21
|
+
const fetchMock = mock(async () => new Response(null, { status: 200 }));
|
|
22
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
23
|
+
|
|
24
|
+
renderWithProviders(<ConfirmAccountUnlockScreen token="unlock-tok" loginHref="/home" />);
|
|
25
|
+
|
|
26
|
+
await waitFor(() => {
|
|
27
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
28
|
+
"/api/auth/confirm-account-unlock",
|
|
29
|
+
expect.objectContaining({
|
|
30
|
+
method: "POST",
|
|
31
|
+
body: JSON.stringify({ token: "unlock-tok" }),
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
expect(screen.getByText("Konto entsperrt")).toBeTruthy();
|
|
35
|
+
});
|
|
36
|
+
expect(screen.getByRole("link", { name: "Zum Login" }).getAttribute("href")).toBe("/home");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("mit Token + 422 → error state", async () => {
|
|
40
|
+
globalThis.fetch = mock(
|
|
41
|
+
async () =>
|
|
42
|
+
new Response(JSON.stringify({ error: { code: "invalid_unlock_token" } }), { status: 422 }),
|
|
43
|
+
) as unknown as typeof fetch;
|
|
44
|
+
|
|
45
|
+
renderWithProviders(<ConfirmAccountUnlockScreen token="bad" />);
|
|
46
|
+
|
|
47
|
+
await waitFor(() => {
|
|
48
|
+
expect(screen.getByText("Entsperren fehlgeschlagen")).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
expect(screen.getByRole("link", { name: "Zum Login" })).toBeTruthy();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("custom title on missing-token uses errorTitle slot only when set", () => {
|
|
54
|
+
renderWithProviders(<ConfirmAccountUnlockScreen title="Unlock kaputt" />);
|
|
55
|
+
expect(screen.getByText("Unlock kaputt")).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
});
|