@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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 +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
- package/src/audit/__tests__/audit.integration.test.ts +50 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +8 -0
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
- package/src/sessions/feature.ts +12 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +67 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/run-forget-cleanup.ts +27 -7
|
@@ -362,6 +362,25 @@ describe("invite-accept-with-login (Branch 2: anon + existing email)", () => {
|
|
|
362
362
|
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
363
363
|
expect(body.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
|
|
364
364
|
});
|
|
365
|
+
|
|
366
|
+
test("common password → 400 (schema rejects breach-list password) (#1340)", async () => {
|
|
367
|
+
const token = await inviteEmail(BOB_EMAIL, "Editor");
|
|
368
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
369
|
+
token,
|
|
370
|
+
email: BOB_EMAIL,
|
|
371
|
+
password: "password1",
|
|
372
|
+
});
|
|
373
|
+
expect(res.status).toBe(400);
|
|
374
|
+
// A 400 for a completely different reason (bad token format, email
|
|
375
|
+
// shape) would also pass a bare status assertion — pin the field-level
|
|
376
|
+
// reason so the test actually proves the breach-list rejection fired.
|
|
377
|
+
const body = (await res.json()) as {
|
|
378
|
+
error?: { details?: { fields?: ReadonlyArray<{ path?: string; code?: string }> } };
|
|
379
|
+
};
|
|
380
|
+
expect(body.error?.details?.fields).toContainEqual(
|
|
381
|
+
expect.objectContaining({ path: "password", code: "custom" }),
|
|
382
|
+
);
|
|
383
|
+
});
|
|
365
384
|
});
|
|
366
385
|
|
|
367
386
|
describe("invite-signup-complete (Branch 3: anon + new email)", () => {
|
|
@@ -397,6 +416,21 @@ describe("invite-signup-complete (Branch 3: anon + new email)", () => {
|
|
|
397
416
|
expect(loginRes.status).toBe(200);
|
|
398
417
|
});
|
|
399
418
|
|
|
419
|
+
test("common password → 400 (schema rejects breach-list password) (#1340)", async () => {
|
|
420
|
+
const token = await inviteEmail(CAROL_EMAIL, "Editor");
|
|
421
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
|
|
422
|
+
token,
|
|
423
|
+
password: "password1",
|
|
424
|
+
});
|
|
425
|
+
expect(res.status).toBe(400);
|
|
426
|
+
const body = (await res.json()) as {
|
|
427
|
+
error?: { details?: { fields?: ReadonlyArray<{ path?: string; code?: string }> } };
|
|
428
|
+
};
|
|
429
|
+
expect(body.error?.details?.fields).toContainEqual(
|
|
430
|
+
expect.objectContaining({ path: "password", code: "custom" }),
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
|
|
400
434
|
test("Existing email → invalid_invite_token (User soll Branch 2 nutzen)", async () => {
|
|
401
435
|
const token = await inviteEmail(BOB_EMAIL, "Admin");
|
|
402
436
|
|
|
@@ -239,31 +239,26 @@ describe("auth-self-registration toggle — companion feature NOT composed", ()
|
|
|
239
239
|
await bareStack.cleanup();
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
// moment an app wires a real effectiveFeatures resolver without also
|
|
249
|
-
// composing createAuthSelfRegistrationToggleFeature. This pins the
|
|
250
|
-
// CURRENT (broken) behavior rather than asserting the desired one —
|
|
251
|
-
// flagged for a follow-up decision, not silently fixed here.
|
|
252
|
-
test("[KNOWN GAP] signup-request silently drops mail when the companion toggle feature was never composed", async () => {
|
|
242
|
+
// Fixed (#1468): ctx.hasFeature() is fail-closed for a name nothing ever
|
|
243
|
+
// registered — signup-request and the status query now only gate on the
|
|
244
|
+
// toggle when registry.getFeature(AUTH_SELF_REGISTRATION_FEATURE) proves
|
|
245
|
+
// the companion feature is actually composed, so an app that mounts
|
|
246
|
+
// auth-email-password without it keeps self-signup working.
|
|
247
|
+
test("signup-request still sends mail when the companion toggle feature was never composed", async () => {
|
|
253
248
|
const res = await bareStack.http.raw("POST", "/api/auth/signup-request", {
|
|
254
249
|
email: "not-toggle-aware@example.com",
|
|
255
250
|
});
|
|
256
251
|
expect(res.status).toBe(200);
|
|
257
|
-
expect(bareEmailTransport.sent).toHaveLength(
|
|
252
|
+
expect(bareEmailTransport.sent).toHaveLength(1);
|
|
258
253
|
});
|
|
259
254
|
|
|
260
|
-
test("
|
|
255
|
+
test("signup-registration-status reports enabled:true with no toggle feature mounted", async () => {
|
|
261
256
|
const res = await bareStack.http.raw("POST", "/api/query", {
|
|
262
257
|
type: "auth-email-password:query:signup-registration-status",
|
|
263
258
|
payload: {},
|
|
264
259
|
});
|
|
265
260
|
expect((await res.json()) as { data?: { enabled: boolean } }).toMatchObject({
|
|
266
|
-
data: { enabled:
|
|
261
|
+
data: { enabled: true },
|
|
267
262
|
});
|
|
268
263
|
});
|
|
269
264
|
});
|
|
@@ -120,6 +120,22 @@ beforeAll(async () => {
|
|
|
120
120
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
121
121
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
122
122
|
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
123
|
+
|
|
124
|
+
// Prime request-helper's per-user sid cache for systemAdmin (the seed
|
|
125
|
+
// actor every test's seedUser() call authenticates as) here, once,
|
|
126
|
+
// before any test's observation window opens — otherwise whichever
|
|
127
|
+
// test happens to call seedUser() first would observe an extra
|
|
128
|
+
// store.created entry for seed plumbing, not the auth-route behavior
|
|
129
|
+
// under test (#1452: authHeader() now mints one sid per user.id, not
|
|
130
|
+
// one per call, so this only ever fires here).
|
|
131
|
+
await seedUser({
|
|
132
|
+
email: "warmup-seed-actor@example.com",
|
|
133
|
+
password: "warmup-password-not-asserted-on",
|
|
134
|
+
tenants: [],
|
|
135
|
+
});
|
|
136
|
+
store.live.clear();
|
|
137
|
+
store.created.length = 0;
|
|
138
|
+
store.revoked.length = 0;
|
|
123
139
|
});
|
|
124
140
|
|
|
125
141
|
afterAll(async () => {
|
|
@@ -158,11 +174,6 @@ async function seedUser(opts: {
|
|
|
158
174
|
roles: t.roles,
|
|
159
175
|
});
|
|
160
176
|
}
|
|
161
|
-
// request-helper mints a sid for systemAdmin when sessionCreator is wired
|
|
162
|
-
// (#1372) — that is seed plumbing, not the auth-route behavior under test.
|
|
163
|
-
store.live.clear();
|
|
164
|
-
store.created.length = 0;
|
|
165
|
-
store.revoked.length = 0;
|
|
166
177
|
return { userId: created.id };
|
|
167
178
|
}
|
|
168
179
|
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
28
28
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
29
29
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
30
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
30
31
|
import {
|
|
31
32
|
setupTestStack,
|
|
32
33
|
type TestStack,
|
|
@@ -57,6 +58,41 @@ import { createAuthEmailPasswordFeature } from "../feature";
|
|
|
57
58
|
|
|
58
59
|
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
59
60
|
|
|
61
|
+
// Regression-proof for #1478: provisionSignupAccount only threaded `hooks`
|
|
62
|
+
// into seedTenant, leaving seedUserWithPassword's `user`-entity and
|
|
63
|
+
// seedTenantMembership's `tenantMembership`-entity postSave hooks unfired
|
|
64
|
+
// on self-signup — same bug class as #1463, just for the other two
|
|
65
|
+
// entities in the same call. This vendor feature's hook is the user-side
|
|
66
|
+
// analog of tier-engine's tenant hook above.
|
|
67
|
+
const userPostSaveFired = { count: 0 };
|
|
68
|
+
const userHookVendorFeature = defineFeature("user-hook-vendor-test", (r) => {
|
|
69
|
+
r.requires("user");
|
|
70
|
+
r.hook("postSave", { allOf: "user" }, async (ctx) => {
|
|
71
|
+
if (ctx.isNew) userPostSaveFired.count++;
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Regression-proof for #1478/#1526: seedTenantMembership must fire the
|
|
76
|
+
// `tenantMembership` entity's postSave hooks (e.g. a Welcome-notification
|
|
77
|
+
// hook) on self-signup too — pins the `fireEntityPostSave` call added at
|
|
78
|
+
// seeding.ts's seedTenantMembership, not just the `user`/`tenant` ones.
|
|
79
|
+
// #1566: also record ctx._tenantId so a shallow-only rescope regresses.
|
|
80
|
+
const tenantMembershipPostSaveFired = { count: 0, seenTenantIds: [] as string[] };
|
|
81
|
+
const tenantMembershipHookVendorFeature = defineFeature(
|
|
82
|
+
"tenant-membership-hook-vendor-test",
|
|
83
|
+
(r) => {
|
|
84
|
+
r.requires("tenant");
|
|
85
|
+
r.hook("postSave", { allOf: "tenant-membership" }, async (result, context) => {
|
|
86
|
+
if (result.isNew) {
|
|
87
|
+
tenantMembershipPostSaveFired.count++;
|
|
88
|
+
if (context._tenantId !== undefined) {
|
|
89
|
+
tenantMembershipPostSaveFired.seenTenantIds.push(String(context._tenantId));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
60
96
|
const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
|
|
61
97
|
|
|
62
98
|
// Activation mails now go through delivery (ctx.notify → channel-email). The
|
|
@@ -92,6 +128,8 @@ beforeAll(async () => {
|
|
|
92
128
|
defaultTier: "free",
|
|
93
129
|
tierMap: { free: { features: [], caps: {} } },
|
|
94
130
|
}),
|
|
131
|
+
userHookVendorFeature,
|
|
132
|
+
tenantMembershipHookVendorFeature,
|
|
95
133
|
],
|
|
96
134
|
extraContext: (deps) => ({
|
|
97
135
|
...createDeliveryTestContext(deps),
|
|
@@ -130,6 +168,9 @@ beforeEach(async () => {
|
|
|
130
168
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tierAssignmentTable.tableName}"`);
|
|
131
169
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
|
|
132
170
|
emailTransport.sent.length = 0;
|
|
171
|
+
userPostSaveFired.count = 0;
|
|
172
|
+
tenantMembershipPostSaveFired.count = 0;
|
|
173
|
+
tenantMembershipPostSaveFired.seenTenantIds = [];
|
|
133
174
|
// Redis-cleanup damit Resend-Tests keine state-leaks haben.
|
|
134
175
|
const allKeys = await stack.redis.redis.keys("signup:*");
|
|
135
176
|
if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
|
|
@@ -245,6 +286,19 @@ describe("POST /api/auth/signup-confirm", () => {
|
|
|
245
286
|
expect(tierRows).toHaveLength(1);
|
|
246
287
|
expect(tierRows[0]?.["tier"]).toBe("free");
|
|
247
288
|
|
|
289
|
+
// #1478 regression: seedUserWithPassword (called from
|
|
290
|
+
// provisionSignupAccount) fires the `user` entity's postSave hooks too —
|
|
291
|
+
// before this fix, only seedTenant's hooks param was wired through.
|
|
292
|
+
expect(userPostSaveFired.count).toBe(1);
|
|
293
|
+
|
|
294
|
+
// #1478/#1526 regression: seedTenantMembership (called from
|
|
295
|
+
// provisionSignupAccount) fires the `tenant-membership` entity's postSave
|
|
296
|
+
// hooks too — before this fix, only seedTenant's hooks param was wired.
|
|
297
|
+
expect(tenantMembershipPostSaveFired.count).toBe(1);
|
|
298
|
+
// #1566: rescope must put the new tenant on the hook context, not the
|
|
299
|
+
// anonymous/system caller's tenantId.
|
|
300
|
+
expect(tenantMembershipPostSaveFired.seenTenantIds).toEqual([body.user?.tenantId ?? ""]);
|
|
301
|
+
|
|
248
302
|
// Authority-Beweis: Login mit dem gesetzten Password funktioniert.
|
|
249
303
|
const loginRes = await postLogin(email, password);
|
|
250
304
|
expect(loginRes.status).toBe(200);
|
|
@@ -46,11 +46,12 @@ import {
|
|
|
46
46
|
getInvitationIdForToken,
|
|
47
47
|
unburnInviteToken,
|
|
48
48
|
} from "../invite-token-store";
|
|
49
|
+
import { passwordSchema } from "../password-policy";
|
|
49
50
|
|
|
50
51
|
const InviteAcceptWithLoginSchema = z.object({
|
|
51
52
|
token: z.string().min(1),
|
|
52
53
|
email: z.email(),
|
|
53
|
-
password:
|
|
54
|
+
password: passwordSchema,
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
export type InviteAcceptWithLoginData = {
|
|
@@ -50,12 +50,13 @@ import {
|
|
|
50
50
|
getInvitationIdForToken,
|
|
51
51
|
unburnInviteToken,
|
|
52
52
|
} from "../invite-token-store";
|
|
53
|
+
import { passwordSchema } from "../password-policy";
|
|
53
54
|
// kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
|
|
54
55
|
import { seedUserWithPassword } from "../seeding";
|
|
55
56
|
|
|
56
57
|
const InviteSignupCompleteSchema = z.object({
|
|
57
58
|
token: z.string().min(1),
|
|
58
|
-
password:
|
|
59
|
+
password: passwordSchema,
|
|
59
60
|
});
|
|
60
61
|
|
|
61
62
|
export type InviteSignupCompleteData = {
|
|
@@ -113,6 +113,14 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
113
113
|
// for a timing-oracle on the bcrypt verify). If Redis isn't wired,
|
|
114
114
|
// lockout is silently skipped — login still works, brute-force
|
|
115
115
|
// protection just degrades to the IP-rate-limiter at the edge.
|
|
116
|
+
//
|
|
117
|
+
// Deliberately fail-open here, unlike auth-mfa's enable-confirm-preauth
|
|
118
|
+
// (which fails closed without Redis): the secret guarded by THIS gate
|
|
119
|
+
// is a full password, not a 6-digit code — locking out every login
|
|
120
|
+
// app-wide because Redis is briefly unavailable is a self-inflicted
|
|
121
|
+
// outage across every tenant, for a backstop that the IP-rate-limiter
|
|
122
|
+
// still partially covers. auth-mfa's blast radius is one user's MFA
|
|
123
|
+
// enrollment, not global login availability — different tradeoff.
|
|
116
124
|
if (ctx.redis) {
|
|
117
125
|
const state = await getLockoutState(ctx.redis, found.id);
|
|
118
126
|
if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
|
|
@@ -14,7 +14,10 @@ export const selfRegistrationStatusQuery = defineQueryHandler({
|
|
|
14
14
|
name: "signup-registration-status",
|
|
15
15
|
schema: z.object({}),
|
|
16
16
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
17
|
-
handler: async (_query, ctx) =>
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
handler: async (_query, ctx) => {
|
|
18
|
+
// Same fail-open reasoning as signup-request.write.ts (#1468): only
|
|
19
|
+
// report disabled when the toggle feature is actually composed.
|
|
20
|
+
const gated = ctx.registry.getFeature(AUTH_SELF_REGISTRATION_FEATURE) !== undefined;
|
|
21
|
+
return { enabled: gated ? await ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE) : true };
|
|
22
|
+
},
|
|
20
23
|
});
|
|
@@ -76,7 +76,18 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
76
76
|
// email" either way. The client-visible signal is the `status` query
|
|
77
77
|
// on auth-self-registration, which the signup page uses to hide its
|
|
78
78
|
// own link/form instead of collecting input that silently no-ops.
|
|
79
|
-
|
|
79
|
+
// Fail-open when the companion toggle feature isn't composed at all —
|
|
80
|
+
// ctx.hasFeature() is fail-closed for *unregistered* names the moment
|
|
81
|
+
// feature-toggles/tier-engine is wired, which silently kills
|
|
82
|
+
// self-signup for every existing app that mounts auth-email-password
|
|
83
|
+
// without also composing createAuthSelfRegistrationToggleFeature()
|
|
84
|
+
// (#1468). Only gate when the toggle actually exists.
|
|
85
|
+
const gated = ctx.registry.getFeature(AUTH_SELF_REGISTRATION_FEATURE) !== undefined;
|
|
86
|
+
if (gated && !(await ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE))) {
|
|
87
|
+
// No email address here — this fires on every no-op request, PII
|
|
88
|
+
// must not accumulate in logs just for the "was this toggled off"
|
|
89
|
+
// signal a support ticket needs.
|
|
90
|
+
ctx.log?.info("signup-request skipped: self-registration disabled");
|
|
80
91
|
return { isSuccess: true, data: { kind: "no-op" } };
|
|
81
92
|
}
|
|
82
93
|
if (!ctx.redis) {
|
|
@@ -89,6 +89,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
89
89
|
"auth.requestUnlock.successBody":
|
|
90
90
|
"Falls die E-Mail in unserem System existiert und gesperrt ist, ist eine Nachricht mit einem Entsperren-Link unterwegs. Bitte schau in deinen Posteingang.",
|
|
91
91
|
"auth.requestUnlock.backToLogin": "Zurück zum Login",
|
|
92
|
+
"auth.requestUnlock.rateLimited": "Zu viele Anfragen. Neuer Versuch in {minutes} Minuten.",
|
|
92
93
|
"auth.unlockAccount.verifying": "Konto wird entsperrt …",
|
|
93
94
|
"auth.unlockAccount.successTitle": "Konto entsperrt",
|
|
94
95
|
"auth.unlockAccount.successBody":
|
|
@@ -215,6 +216,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
215
216
|
"auth.requestUnlock.successBody":
|
|
216
217
|
"If that email exists in our system and is locked, a message with an unlock link is on its way. Please check your inbox.",
|
|
217
218
|
"auth.requestUnlock.backToLogin": "Back to sign in",
|
|
219
|
+
"auth.requestUnlock.rateLimited": "Too many requests. Try again in {minutes} minutes.",
|
|
218
220
|
"auth.unlockAccount.verifying": "Unlocking account …",
|
|
219
221
|
"auth.unlockAccount.successTitle": "Account unlocked",
|
|
220
222
|
"auth.unlockAccount.successBody": "Your account is unlocked again. You can sign in now.",
|
|
@@ -55,17 +55,25 @@ export type SeedUserWithPasswordOptions = {
|
|
|
55
55
|
export async function seedUserWithPassword(
|
|
56
56
|
db: DbConnection,
|
|
57
57
|
options: SeedUserWithPasswordOptions,
|
|
58
|
+
// Optional, append-only — same rationale as provisionSignupAccount's
|
|
59
|
+
// hooks param (#1478): the self-signup path passes it through so `user`'s
|
|
60
|
+
// postSave hooks fire on self-signup same as on the regular create path.
|
|
61
|
+
hooks?: SeedTenantHooks,
|
|
58
62
|
): Promise<{ id: string }> {
|
|
59
63
|
const passwordHash = await hashPassword(options.password);
|
|
60
|
-
return seedUser(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
return seedUser(
|
|
65
|
+
db,
|
|
66
|
+
{
|
|
67
|
+
email: options.email,
|
|
68
|
+
displayName: options.displayName,
|
|
69
|
+
passwordHash,
|
|
70
|
+
...(options.locale !== undefined && { locale: options.locale }),
|
|
71
|
+
...(options.roles !== undefined && { roles: options.roles }),
|
|
72
|
+
...(options.emailVerified !== undefined && { emailVerified: options.emailVerified }),
|
|
73
|
+
...(options.by !== undefined && { by: options.by }),
|
|
74
|
+
},
|
|
75
|
+
hooks,
|
|
76
|
+
);
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
/** Provisioning-Helper für Self-Signup-Confirm. Legt einen frischen
|
|
@@ -117,9 +125,11 @@ export async function provisionSignupAccount(
|
|
|
117
125
|
options: ProvisionSignupAccountOptions,
|
|
118
126
|
// Optional, append-only — existing callers (tests, seed scripts) that
|
|
119
127
|
// don't pass hooks keep today's behavior. The real signup-confirm
|
|
120
|
-
// handler DOES pass it (#1463) so seedTenant
|
|
121
|
-
// (tier-engine's auto-default-tier,
|
|
122
|
-
//
|
|
128
|
+
// handler DOES pass it (#1463) so seedTenant/seedUserWithPassword/
|
|
129
|
+
// seedTenantMembership's postSave hooks (tier-engine's auto-default-tier,
|
|
130
|
+
// app auto-default-compliance, a Welcome-notification hook, ...) fire on
|
|
131
|
+
// self-signup for all three entities, same as they do on the regular
|
|
132
|
+
// create paths (#1478 — this was only wired for tenant before).
|
|
123
133
|
hooks?: SeedTenantHooks,
|
|
124
134
|
): Promise<{ readonly userId: string; readonly tenantId: TenantId }> {
|
|
125
135
|
// Create-only-Guard VOR seedTenant: bei bereits registrierter Email hart
|
|
@@ -139,17 +149,25 @@ export async function provisionSignupAccount(
|
|
|
139
149
|
},
|
|
140
150
|
hooks,
|
|
141
151
|
);
|
|
142
|
-
const { id: userId } = await seedUserWithPassword(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
const { id: userId } = await seedUserWithPassword(
|
|
153
|
+
db,
|
|
154
|
+
{
|
|
155
|
+
email: options.email,
|
|
156
|
+
password: options.password,
|
|
157
|
+
displayName: options.displayName,
|
|
158
|
+
emailVerified: true,
|
|
159
|
+
},
|
|
160
|
+
hooks,
|
|
161
|
+
);
|
|
162
|
+
await seedTenantMembership(
|
|
163
|
+
db,
|
|
164
|
+
{
|
|
165
|
+
userId,
|
|
166
|
+
tenantId: options.tenantId,
|
|
167
|
+
roles: options.memberRoles ?? INITIAL_SIGNUP_ROLES,
|
|
168
|
+
},
|
|
169
|
+
hooks,
|
|
170
|
+
);
|
|
153
171
|
return { userId, tenantId: options.tenantId };
|
|
154
172
|
}
|
|
155
173
|
|
|
@@ -33,6 +33,8 @@ function withoutCsrfDocument(): void {
|
|
|
33
33
|
delete (globalThis as { document?: { cookie: string } }).document;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
const originalFetch = globalThis.fetch;
|
|
37
|
+
|
|
36
38
|
beforeEach(() => {
|
|
37
39
|
globalThis.fetch = mock(
|
|
38
40
|
async () => new Response(null, { status: 200 }),
|
|
@@ -41,6 +43,7 @@ beforeEach(() => {
|
|
|
41
43
|
|
|
42
44
|
afterEach(() => {
|
|
43
45
|
withoutCsrfDocument();
|
|
46
|
+
globalThis.fetch = originalFetch;
|
|
44
47
|
});
|
|
45
48
|
|
|
46
49
|
describe("csrfHeader", () => {
|
|
@@ -107,6 +110,16 @@ describe("login", () => {
|
|
|
107
110
|
expect(res).toEqual({ kind: "mfa-setup-required", preauthSetupToken: "setup-token-value" });
|
|
108
111
|
});
|
|
109
112
|
|
|
113
|
+
test("mfaSetupRequired without preauthSetupToken → explicit mfa_setup_required failure", async () => {
|
|
114
|
+
globalThis.fetch = mock(async () =>
|
|
115
|
+
jsonResponse({ isSuccess: true, mfaSetupRequired: true }),
|
|
116
|
+
) as unknown as typeof fetch;
|
|
117
|
+
|
|
118
|
+
const res = await login({ email: "a@b.c", password: "secret" });
|
|
119
|
+
|
|
120
|
+
expect(res).toEqual({ kind: "failure", error: { reason: "mfa_setup_required" } });
|
|
121
|
+
});
|
|
122
|
+
|
|
110
123
|
test("string error → kind:failure with reason from string", async () => {
|
|
111
124
|
globalThis.fetch = mock(async () =>
|
|
112
125
|
jsonResponse({ isSuccess: false, error: "invalid_credentials" }),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import { fireEvent, screen } from "@testing-library/react";
|
|
2
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
import { createLoginRoute, makeAuthGate } from "../auth-gate";
|
|
5
5
|
import { makeSessionApi, renderWithProviders } from "./test-utils";
|
|
@@ -10,7 +10,7 @@ describe("makeAuthGate", () => {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
test("loading → renders placeholder, not children, not login", () => {
|
|
13
|
-
const Gate = makeAuthGate(CustomLogin);
|
|
13
|
+
const Gate = makeAuthGate({ loginScreen: CustomLogin });
|
|
14
14
|
const session = makeSessionApi({ status: "loading", user: null });
|
|
15
15
|
const { container } = renderWithProviders(
|
|
16
16
|
<Gate>
|
|
@@ -25,7 +25,7 @@ describe("makeAuthGate", () => {
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
test("unauthenticated → renders LoginComponent, not children", () => {
|
|
28
|
-
const Gate = makeAuthGate(CustomLogin);
|
|
28
|
+
const Gate = makeAuthGate({ loginScreen: CustomLogin });
|
|
29
29
|
const session = makeSessionApi({ status: "unauthenticated", user: null });
|
|
30
30
|
renderWithProviders(
|
|
31
31
|
<Gate>
|
|
@@ -38,7 +38,7 @@ describe("makeAuthGate", () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
test("authenticated → renders children, not login", () => {
|
|
41
|
-
const Gate = makeAuthGate(CustomLogin);
|
|
41
|
+
const Gate = makeAuthGate({ loginScreen: CustomLogin });
|
|
42
42
|
renderWithProviders(
|
|
43
43
|
<Gate>
|
|
44
44
|
<div data-testid="protected">secret</div>
|
|
@@ -134,8 +134,30 @@ describe("createLoginRoute", () => {
|
|
|
134
134
|
expect(screen.getByTestId("mfa-verify").textContent).toBe("token-123");
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
test("no mfaVerifyScreen → onMfaChallenge passes through to loginScreenProps.onMfaChallenge (#266 footgun)", () => {
|
|
138
|
+
// The silent-no-MFA-fallback path: an app that wires its own
|
|
139
|
+
// onMfaChallenge via loginScreenProps but forgets mfaVerifyScreen still
|
|
140
|
+
// gets that callback invoked (createLoginRoute can't detect the
|
|
141
|
+
// omission) instead of the internal setChallengeToken/MfaVerifyComponent
|
|
142
|
+
// machinery kicking in. Covered here so a future refactor of that
|
|
143
|
+
// ternary can't silently swap the fallback direction.
|
|
144
|
+
const onMfaChallenge = mock<(challengeToken: string) => void>();
|
|
145
|
+
const LoginRoute = createLoginRoute({
|
|
146
|
+
loginScreen: LoginWithMfaTrigger,
|
|
147
|
+
loginScreenProps: { onMfaChallenge },
|
|
148
|
+
});
|
|
149
|
+
const session = makeSessionApi({ status: "unauthenticated" });
|
|
150
|
+
renderWithProviders(<LoginRoute />, { session });
|
|
151
|
+
fireEvent.click(screen.getByTestId("trigger-mfa"));
|
|
152
|
+
expect(onMfaChallenge).toHaveBeenCalledWith("token-123");
|
|
153
|
+
expect(screen.queryByTestId("mfa-verify")).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
137
156
|
test("makeAuthGate delegates mfaVerifyScreen wiring to createLoginRoute", () => {
|
|
138
|
-
const Gate = makeAuthGate(
|
|
157
|
+
const Gate = makeAuthGate({
|
|
158
|
+
loginScreen: LoginWithMfaTrigger,
|
|
159
|
+
mfaVerifyScreen: CustomMfaVerify,
|
|
160
|
+
});
|
|
139
161
|
const session = makeSessionApi({ status: "unauthenticated" });
|
|
140
162
|
renderWithProviders(
|
|
141
163
|
<Gate>
|
|
@@ -160,7 +182,7 @@ describe("createLoginRoute", () => {
|
|
|
160
182
|
);
|
|
161
183
|
});
|
|
162
184
|
|
|
163
|
-
test("MfaSetupComponent onSuccess → gate clears the request and refreshes the session", () => {
|
|
185
|
+
test("MfaSetupComponent onSuccess → gate clears the request and refreshes the session", async () => {
|
|
164
186
|
const LoginRoute = createLoginRoute({
|
|
165
187
|
loginScreen: LoginWithMfaSetupTrigger,
|
|
166
188
|
mfaSetupScreen: CustomMfaSetup,
|
|
@@ -171,11 +193,36 @@ describe("createLoginRoute", () => {
|
|
|
171
193
|
expect(screen.getByTestId("mfa-setup")).toBeTruthy();
|
|
172
194
|
fireEvent.click(screen.getByTestId("complete-mfa-setup"));
|
|
173
195
|
expect(session.refresh).toHaveBeenCalledTimes(1);
|
|
174
|
-
|
|
196
|
+
await waitFor(() => {
|
|
197
|
+
expect(screen.queryByTestId("mfa-setup")).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("MfaSetupComponent onSuccess → keeps setup screen when refresh rejects", async () => {
|
|
202
|
+
const LoginRoute = createLoginRoute({
|
|
203
|
+
loginScreen: LoginWithMfaSetupTrigger,
|
|
204
|
+
mfaSetupScreen: CustomMfaSetup,
|
|
205
|
+
});
|
|
206
|
+
const session = makeSessionApi({
|
|
207
|
+
status: "unauthenticated",
|
|
208
|
+
refresh: mock(async () => {
|
|
209
|
+
throw new Error("refresh failed");
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
212
|
+
renderWithProviders(<LoginRoute />, { session });
|
|
213
|
+
fireEvent.click(screen.getByTestId("trigger-mfa-setup"));
|
|
214
|
+
fireEvent.click(screen.getByTestId("complete-mfa-setup"));
|
|
215
|
+
expect(session.refresh).toHaveBeenCalledTimes(1);
|
|
216
|
+
await Promise.resolve();
|
|
217
|
+
await Promise.resolve();
|
|
218
|
+
expect(screen.getByTestId("mfa-setup")).toBeTruthy();
|
|
175
219
|
});
|
|
176
220
|
|
|
177
221
|
test("makeAuthGate delegates mfaSetupScreen wiring to createLoginRoute", () => {
|
|
178
|
-
const Gate = makeAuthGate(
|
|
222
|
+
const Gate = makeAuthGate({
|
|
223
|
+
loginScreen: LoginWithMfaSetupTrigger,
|
|
224
|
+
mfaSetupScreen: CustomMfaSetup,
|
|
225
|
+
});
|
|
179
226
|
const session = makeSessionApi({ status: "unauthenticated" });
|
|
180
227
|
renderWithProviders(
|
|
181
228
|
<Gate>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// #1472: emailPasswordClient() built the session gate without threading
|
|
4
|
+
// options.mfaSetupScreen through to makeSessionAuthGate — the option was
|
|
5
|
+
// typed and documented but silently dropped on the standard mount path.
|
|
6
|
+
// makeSessionAuthGate wraps its own real SessionProvider (unlike
|
|
7
|
+
// makeAuthGate, which auth-gate.test.tsx exercises directly), so the
|
|
8
|
+
// cheapest live check is spying on the call args instead of rendering
|
|
9
|
+
// through a real session fetch.
|
|
10
|
+
const makeSessionAuthGateCalls: unknown[][] = [];
|
|
11
|
+
mock.module("../auth-gate", () => ({
|
|
12
|
+
makeSessionAuthGate: (...args: unknown[]) => {
|
|
13
|
+
makeSessionAuthGateCalls.push(args);
|
|
14
|
+
return () => null;
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const { emailPasswordClient } = await import("../client-plugin");
|
|
19
|
+
|
|
20
|
+
describe("emailPasswordClient — mfaSetupScreen wiring", () => {
|
|
21
|
+
test("passes options.mfaSetupScreen through to makeSessionAuthGate", () => {
|
|
22
|
+
makeSessionAuthGateCalls.length = 0;
|
|
23
|
+
function CustomMfaSetup() {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
emailPasswordClient({ mfaSetupScreen: CustomMfaSetup });
|
|
28
|
+
|
|
29
|
+
expect(makeSessionAuthGateCalls).toHaveLength(1);
|
|
30
|
+
expect((makeSessionAuthGateCalls[0]?.[0] as { mfaSetupScreen?: unknown })?.mfaSetupScreen).toBe(
|
|
31
|
+
CustomMfaSetup,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -3,12 +3,16 @@ import { screen, waitFor } from "@testing-library/react";
|
|
|
3
3
|
import { ConfirmAccountUnlockScreen } from "../confirm-account-unlock-screen";
|
|
4
4
|
import { renderWithProviders } from "./test-utils";
|
|
5
5
|
|
|
6
|
+
const originalFetch = globalThis.fetch;
|
|
7
|
+
|
|
6
8
|
beforeEach(() => {
|
|
7
9
|
globalThis.fetch = mock(
|
|
8
10
|
async () => new Response(null, { status: 200 }),
|
|
9
11
|
) as unknown as typeof fetch;
|
|
10
12
|
});
|
|
11
|
-
afterEach(() => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
globalThis.fetch = originalFetch;
|
|
15
|
+
});
|
|
12
16
|
|
|
13
17
|
describe("ConfirmAccountUnlockScreen", () => {
|
|
14
18
|
test("ohne Token → missing-token page", () => {
|
|
@@ -28,6 +32,8 @@ describe("ConfirmAccountUnlockScreen", () => {
|
|
|
28
32
|
"/api/auth/confirm-account-unlock",
|
|
29
33
|
expect.objectContaining({
|
|
30
34
|
method: "POST",
|
|
35
|
+
headers: expect.objectContaining({ "Content-Type": "application/json" }),
|
|
36
|
+
credentials: "same-origin",
|
|
31
37
|
body: JSON.stringify({ token: "unlock-tok" }),
|
|
32
38
|
}),
|
|
33
39
|
);
|
|
@@ -53,5 +59,6 @@ describe("ConfirmAccountUnlockScreen", () => {
|
|
|
53
59
|
test("custom title on missing-token uses errorTitle slot only when set", () => {
|
|
54
60
|
renderWithProviders(<ConfirmAccountUnlockScreen title="Unlock kaputt" />);
|
|
55
61
|
expect(screen.getByText("Unlock kaputt")).toBeTruthy();
|
|
62
|
+
expect(screen.queryByText("Entsperren fehlgeschlagen")).toBeNull();
|
|
56
63
|
});
|
|
57
64
|
});
|
|
@@ -3,12 +3,16 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
|
3
3
|
import { ForgotPasswordScreen } from "../forgot-password-screen";
|
|
4
4
|
import { renderWithProviders } from "./test-utils";
|
|
5
5
|
|
|
6
|
+
const originalFetch = globalThis.fetch;
|
|
7
|
+
|
|
6
8
|
beforeEach(() => {
|
|
7
9
|
globalThis.fetch = mock(
|
|
8
10
|
async () => new Response(null, { status: 200 }),
|
|
9
11
|
) as unknown as typeof fetch;
|
|
10
12
|
});
|
|
11
|
-
afterEach(() => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
globalThis.fetch = originalFetch;
|
|
15
|
+
});
|
|
12
16
|
|
|
13
17
|
describe("ForgotPasswordScreen", () => {
|
|
14
18
|
test("rendert title + email-input + submit-button (de)", () => {
|
|
@@ -27,6 +27,8 @@ function renderWithoutSessionProvider(token: string) {
|
|
|
27
27
|
);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
const originalFetch = globalThis.fetch;
|
|
31
|
+
|
|
30
32
|
beforeEach(() => {
|
|
31
33
|
globalThis.fetch = mock(
|
|
32
34
|
async () => new Response(JSON.stringify({ tenantId: "tenant-new" }), { status: 200 }),
|
|
@@ -43,6 +45,7 @@ afterEach(() => {
|
|
|
43
45
|
writable: true,
|
|
44
46
|
value: originalLocation,
|
|
45
47
|
});
|
|
48
|
+
globalThis.fetch = originalFetch;
|
|
46
49
|
});
|
|
47
50
|
|
|
48
51
|
describe("InviteAcceptScreen — no <SessionProvider> ancestor (632/1)", () => {
|