@cosmicdrift/kumiko-bundled-features 0.105.1 → 0.108.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 +8 -6
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +112 -1
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
- package/src/auth-email-password/web/auth-gate.tsx +3 -4
- package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
- package/src/auth-email-password/web/session.tsx +5 -1
- package/src/auth-email-password/web/user-menu.tsx +1 -1
- package/src/config/__tests__/deserialize-value.test.ts +3 -6
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
- package/src/custom-fields/constants.ts +1 -0
- package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +8 -2
- package/src/custom-fields/wire-for-entity.ts +104 -77
- package/src/data-retention/__tests__/parse-override.test.ts +11 -6
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +76 -1
- package/src/data-retention/resolve-for-tenant.ts +16 -4
- package/src/data-retention/run-retention-cleanup.ts +72 -10
- package/src/delivery/attempt-log.ts +22 -10
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
- package/src/folders/__tests__/folders.integration.test.ts +81 -0
- package/src/folders/feature.ts +4 -2
- package/src/folders/handlers/delete-folder.write.ts +40 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +86 -0
- package/src/folders/web/folder-manager.tsx +27 -15
- package/src/folders/web/i18n.ts +0 -2
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
- package/src/folders-user-data/hooks.ts +31 -8
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/feature.ts +23 -21
- package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
- package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
- package/src/ledger/__tests__/reports.test.ts +20 -0
- package/src/ledger/entity.ts +1 -1
- package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
- package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
- package/src/ledger/recurring.ts +18 -9
- package/src/ledger/schemas.ts +4 -1
- package/src/ledger/web/index.ts +3 -2
- package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +10 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +204 -0
- package/src/personal-access-tokens/constants.ts +33 -0
- package/src/personal-access-tokens/feature.ts +78 -0
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +15 -0
- package/src/personal-access-tokens/handlers/create.write.ts +41 -0
- package/src/personal-access-tokens/handlers/list.query.ts +47 -0
- package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
- package/src/personal-access-tokens/hash.ts +17 -0
- package/src/personal-access-tokens/index.ts +45 -0
- package/src/personal-access-tokens/resolver.ts +58 -0
- package/src/personal-access-tokens/roles.ts +29 -0
- package/src/personal-access-tokens/schema/api-token.ts +56 -0
- package/src/personal-access-tokens/scopes.ts +22 -0
- package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
- package/src/personal-access-tokens/web/i18n.ts +45 -0
- package/src/personal-access-tokens/web/index.ts +7 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +150 -0
- package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
- package/src/sessions/__tests__/sessions.integration.test.ts +29 -8
- package/src/sessions/schema/user-session.ts +8 -2
- package/src/sessions/session-callbacks.ts +9 -3
- package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
- package/src/tags/__tests__/tags.integration.test.ts +15 -0
- package/src/tags/handlers/assign-tag.write.ts +13 -3
- package/src/tags/schemas.ts +10 -4
- package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/template-resolver/testing.ts +4 -3
- package/src/tier-engine/feature.ts +22 -10
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
- package/src/user/__tests__/user.integration.test.ts +36 -0
- package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
- package/src/user/index.ts +4 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +44 -2
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +2 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +69 -6
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +42 -0
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
- package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
- package/src/user-data-rights/run-forget-cleanup.ts +16 -2
- package/src/user-data-rights/screens.ts +7 -6
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +40 -5
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
- package/src/user-data-rights/web/i18n.ts +0 -10
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
- package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
- package/src/user-profile/handlers/change-email.write.ts +11 -8
- package/src/user-profile/web/profile-screen.tsx +10 -8
- package/src/auth-email-password/stream-tenant.ts +0 -31
- package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.108.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>",
|
|
@@ -77,6 +77,8 @@
|
|
|
77
77
|
"./secrets": "./src/secrets/index.ts",
|
|
78
78
|
"./sessions": "./src/sessions/index.ts",
|
|
79
79
|
"./sessions/testing": "./src/sessions/testing.ts",
|
|
80
|
+
"./personal-access-tokens": "./src/personal-access-tokens/index.ts",
|
|
81
|
+
"./personal-access-tokens/web": "./src/personal-access-tokens/web/index.ts",
|
|
80
82
|
"./feature-toggles": "./src/feature-toggles/index.ts",
|
|
81
83
|
"./text-content": "./src/text-content/index.ts",
|
|
82
84
|
"./text-content/seeding": "./src/text-content/seeding.ts",
|
|
@@ -93,11 +95,11 @@
|
|
|
93
95
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
94
96
|
},
|
|
95
97
|
"dependencies": {
|
|
96
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
97
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
98
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
99
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
100
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
98
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.108.0",
|
|
99
|
+
"@cosmicdrift/kumiko-framework": "0.108.0",
|
|
100
|
+
"@cosmicdrift/kumiko-headless": "0.108.0",
|
|
101
|
+
"@cosmicdrift/kumiko-renderer": "0.108.0",
|
|
102
|
+
"@cosmicdrift/kumiko-renderer-web": "0.108.0",
|
|
101
103
|
"@mollie/api-client": "^4.5.0",
|
|
102
104
|
"@node-rs/argon2": "^2.0.2",
|
|
103
105
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { asRawClient
|
|
3
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
4
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
5
5
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
unsafeCreateEntityTable,
|
|
13
13
|
unsafePushTables,
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
15
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
15
16
|
import { createConfigFeature } from "../../config";
|
|
16
17
|
import { createConfigResolver } from "../../config/resolver";
|
|
17
18
|
import { configValuesTable } from "../../config/table";
|
|
@@ -119,7 +120,7 @@ async function seedUser(email: string, password: string): Promise<string> {
|
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
async function addMembership(userId: string, tenantId: TenantId, roles: string[]): Promise<void> {
|
|
122
|
-
await
|
|
123
|
+
await seedRow(stack.db, tenantMembershipsTable, {
|
|
123
124
|
userId,
|
|
124
125
|
tenantId,
|
|
125
126
|
roles: JSON.stringify(roles),
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
asRawClient,
|
|
5
|
-
insertOne,
|
|
6
|
-
selectMany,
|
|
7
|
-
updateMany,
|
|
8
|
-
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
4
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
10
5
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
6
|
import {
|
|
@@ -15,6 +10,7 @@ import {
|
|
|
15
10
|
unsafeCreateEntityTable,
|
|
16
11
|
unsafePushTables,
|
|
17
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
+
import { seedRow, updateRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
18
14
|
import { Temporal } from "temporal-polyfill";
|
|
19
15
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
20
16
|
import { createConfigFeature } from "../../config";
|
|
@@ -146,7 +142,7 @@ async function seedUser(opts: {
|
|
|
146
142
|
// it directly via SQL after create. Row.version is left at 1; no
|
|
147
143
|
// subsequent event-store writes happen on this row in these tests.
|
|
148
144
|
if (opts.emailVerified === true) {
|
|
149
|
-
await
|
|
145
|
+
await updateRows(stack.db, userTable, { emailVerified: true }, { id: created.id });
|
|
150
146
|
}
|
|
151
147
|
const tenantId = opts.tenantId ?? "00000000-0000-4000-8000-000000000001";
|
|
152
148
|
await seedTenantMembership(stack.db, {
|
|
@@ -252,7 +248,7 @@ describe("POST /auth/verify-email", () => {
|
|
|
252
248
|
const firstAttempt = await post("/api/auth/verify-email", { token });
|
|
253
249
|
expect(firstAttempt.status).toBe(422);
|
|
254
250
|
|
|
255
|
-
await
|
|
251
|
+
await seedRow(stack.db, userTable, userRow);
|
|
256
252
|
|
|
257
253
|
const secondAttempt = await post("/api/auth/verify-email", { token });
|
|
258
254
|
expect(secondAttempt.status).toBe(200);
|
|
@@ -296,7 +292,7 @@ describe("POST /auth/verify-email", () => {
|
|
|
296
292
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
297
293
|
|
|
298
294
|
const orphanId = "00000000-0000-4000-8000-0000000000ff";
|
|
299
|
-
await
|
|
295
|
+
await seedRow(stack.db, userTable, {
|
|
300
296
|
...donorRow,
|
|
301
297
|
id: orphanId,
|
|
302
298
|
email: "orphan@example.com",
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
unsafeCreateEntityTable,
|
|
29
29
|
unsafePushTables,
|
|
30
30
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
31
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
31
32
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
32
33
|
import { createConfigFeature } from "../../config";
|
|
33
34
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -38,7 +39,11 @@ import { createRendererFoundationFeature } from "../../renderer-foundation/featu
|
|
|
38
39
|
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
39
40
|
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
40
41
|
import { createTenantFeature } from "../../tenant";
|
|
41
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
INVITATION_STATUS,
|
|
44
|
+
tenantInvitationEntity,
|
|
45
|
+
tenantInvitationsTable,
|
|
46
|
+
} from "../../tenant/invitation-table";
|
|
42
47
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
43
48
|
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
44
49
|
import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
|
|
@@ -46,6 +51,7 @@ import { createUserFeature } from "../../user/feature";
|
|
|
46
51
|
import { userEntity, userTable } from "../../user/schema/user";
|
|
47
52
|
import { AuthErrors, AuthHandlers } from "../constants";
|
|
48
53
|
import { createAuthEmailPasswordFeature } from "../feature";
|
|
54
|
+
import { storeInviteToken } from "../invite-token-store";
|
|
49
55
|
import { hashPassword } from "../password-hashing";
|
|
50
56
|
import { seedUser } from "../seeding";
|
|
51
57
|
|
|
@@ -254,6 +260,12 @@ describe("invite-create", () => {
|
|
|
254
260
|
const rows = await selectMany(stack.db, tenantInvitationsTable, { email: BOB_EMAIL });
|
|
255
261
|
expect(rows).toHaveLength(1);
|
|
256
262
|
expect(rows[0]?.["role"]).toBe("Editor");
|
|
263
|
+
|
|
264
|
+
// inviteEmail() only reads emailTransport.sent.at(-1) — a silent second-
|
|
265
|
+
// dispatch failure would still leave sent.length===1 and .at(-1) pointing
|
|
266
|
+
// at the FIRST mail, making secondToken===firstToken pass for the wrong
|
|
267
|
+
// reason. Assert an actual second dispatch happened.
|
|
268
|
+
expect(emailTransport.sent).toHaveLength(2);
|
|
257
269
|
});
|
|
258
270
|
});
|
|
259
271
|
|
|
@@ -403,6 +415,105 @@ describe("invite-signup-complete (Branch 3: anon + new email)", () => {
|
|
|
403
415
|
});
|
|
404
416
|
});
|
|
405
417
|
|
|
418
|
+
describe("invite-accept defense-in-depth (assertAssignableMembershipRoles)", () => {
|
|
419
|
+
test("a forbidden role planted directly on the invitation row (bug/migration) is rejected, not silently granted", async () => {
|
|
420
|
+
// Simulates the ONE scenario this depth-layer exists for: a forbidden role
|
|
421
|
+
// reaching the invitation row through some path OTHER than invite-create
|
|
422
|
+
// (which already validates at request time — see "privilege escalation"
|
|
423
|
+
// below). A DB migration or direct write is the only realistic vector.
|
|
424
|
+
const fakeInvitationId = crypto.randomUUID();
|
|
425
|
+
await seedRow(stack.db, tenantInvitationsTable, {
|
|
426
|
+
id: fakeInvitationId,
|
|
427
|
+
tenantId: TENANT_A_ID,
|
|
428
|
+
email: BOB_EMAIL,
|
|
429
|
+
role: "SystemAdmin",
|
|
430
|
+
status: INVITATION_STATUS.pending,
|
|
431
|
+
invitedBy: aliceId,
|
|
432
|
+
expiresAt: "2030-01-01T00:00:00Z",
|
|
433
|
+
});
|
|
434
|
+
const token = crypto.randomUUID();
|
|
435
|
+
await storeInviteToken(stack.redis.redis, {
|
|
436
|
+
invitationId: fakeInvitationId,
|
|
437
|
+
token,
|
|
438
|
+
ttlSeconds: 3600,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const res = await authedRaw("POST", "/api/auth/invite-accept", { token }, bobSession());
|
|
442
|
+
expect(res.status).toBe(403);
|
|
443
|
+
const body = (await res.json()) as { error?: { code?: string } };
|
|
444
|
+
expect(body.error?.code).toBe("access_denied");
|
|
445
|
+
|
|
446
|
+
// No membership was granted.
|
|
447
|
+
const memberships = await selectMany(stack.db, tenantMembershipsTable, { userId: bobId });
|
|
448
|
+
expect(memberships).toHaveLength(1); // only the seeded Tenant-B membership
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
describe("invite-accept-with-login/signup-complete defense-in-depth (637/1)", () => {
|
|
453
|
+
// Branch 1 (invite-accept, logged-in) and Branch 3 (invite-signup-complete)
|
|
454
|
+
// both route every new membership through seedTenantMembership, which
|
|
455
|
+
// calls assertAssignableMembershipRoles unconditionally (tenant/seeding.ts)
|
|
456
|
+
// — a forbidden invitation role is rejected outright, 403, before it ever
|
|
457
|
+
// reaches stripForbiddenMembershipRoles. Branch 2 (invite-accept-with-login)
|
|
458
|
+
// is different: it skips seedTenantMembership entirely when the user is
|
|
459
|
+
// ALREADY a member of the invited tenant (idempotent-accept path) — for
|
|
460
|
+
// that one case, stripForbiddenMembershipRoles at the session mint is the
|
|
461
|
+
// ONLY protection. Removing that call would silently mint a
|
|
462
|
+
// SystemAdmin-carrying session with no other test catching it.
|
|
463
|
+
test("invite-accept-with-login: already-member path strips a forbidden invitation role (seedTenantMembership's guard is skipped here)", async () => {
|
|
464
|
+
// Bob already has a (legitimate) membership in TENANT_A_ID — the
|
|
465
|
+
// handler's alreadyMember check short-circuits before seedTenantMembership,
|
|
466
|
+
// so assertAssignableMembershipRoles never runs for this accept.
|
|
467
|
+
await seedTenantMembership(stack.db, {
|
|
468
|
+
userId: bobId,
|
|
469
|
+
tenantId: TENANT_A_ID,
|
|
470
|
+
roles: ["User"],
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// Create through the real command (valid role → real stream + version),
|
|
474
|
+
// then corrupt the row directly — the migration/DB-surgery scenario this
|
|
475
|
+
// depth-layer guards against, same framing as the Branch-1 test above.
|
|
476
|
+
const token = await inviteEmail(BOB_EMAIL, "Admin");
|
|
477
|
+
await asRawClient(stack.db).unsafe(
|
|
478
|
+
`UPDATE "${tenantInvitationsTable.tableName}" SET "role" = 'SystemAdmin' WHERE "email" = $1`,
|
|
479
|
+
[BOB_EMAIL],
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
483
|
+
token,
|
|
484
|
+
email: BOB_EMAIL,
|
|
485
|
+
password: BOB_PASSWORD,
|
|
486
|
+
});
|
|
487
|
+
expect(res.status).toBe(200);
|
|
488
|
+
const body = (await res.json()) as { user: { roles: string[] } };
|
|
489
|
+
expect(body.user.roles).toEqual([]);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("invite-signup-complete: a brand-new user always hits seedTenantMembership's guard — forbidden role rejected, not silently stripped", async () => {
|
|
493
|
+
// Unlike Branch 2, a brand-new user can never be "already a member" —
|
|
494
|
+
// seedTenantMembership (and its assertAssignableMembershipRoles guard)
|
|
495
|
+
// runs unconditionally. Pins that this branch is NOT exposed to the
|
|
496
|
+
// Branch-2 gap, so a future refactor that adds an alreadyMember-style
|
|
497
|
+
// skip here would be caught immediately.
|
|
498
|
+
const daveEmail = "dave@example.com";
|
|
499
|
+
const token = await inviteEmail(daveEmail, "Admin");
|
|
500
|
+
await asRawClient(stack.db).unsafe(
|
|
501
|
+
`UPDATE "${tenantInvitationsTable.tableName}" SET "role" = 'SystemAdmin' WHERE "email" = $1`,
|
|
502
|
+
[daveEmail],
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
|
|
506
|
+
token,
|
|
507
|
+
password: "dave-new-pw-1234",
|
|
508
|
+
});
|
|
509
|
+
expect(res.status).toBe(403);
|
|
510
|
+
|
|
511
|
+
// No user or membership was created — the whole write rolled back.
|
|
512
|
+
const daveRows = await selectMany(stack.db, userTable, { email: daveEmail });
|
|
513
|
+
expect(daveRows).toHaveLength(0);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
406
517
|
describe("Single-Use-Burn (alle Branches)", () => {
|
|
407
518
|
test("Branch 1: zweiter accept mit gleichem Token → invalid", async () => {
|
|
408
519
|
const token = await inviteEmail(BOB_EMAIL, "Admin");
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// (nur tenant-membership-roles in der Session).
|
|
10
10
|
|
|
11
11
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
-
import { asRawClient,
|
|
12
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
13
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
14
|
import {
|
|
15
15
|
setupTestStack,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
unsafeCreateEntityTable,
|
|
20
20
|
unsafePushTables,
|
|
21
21
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
22
23
|
import { createConfigFeature } from "../../config";
|
|
23
24
|
import { createConfigResolver } from "../../config/resolver";
|
|
24
25
|
import { configValuesTable } from "../../config/table";
|
|
@@ -102,7 +103,7 @@ async function addMembership(
|
|
|
102
103
|
tenantId: TenantId,
|
|
103
104
|
roles: readonly string[],
|
|
104
105
|
): Promise<void> {
|
|
105
|
-
await
|
|
106
|
+
await seedRow(stack.db, tenantMembershipsTable, {
|
|
106
107
|
userId,
|
|
107
108
|
tenantId,
|
|
108
109
|
roles: JSON.stringify(roles),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { asRawClient,
|
|
3
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
4
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
5
5
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
unsafeCreateEntityTable,
|
|
11
11
|
unsafePushTables,
|
|
12
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
13
14
|
import { Temporal } from "temporal-polyfill";
|
|
14
15
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
15
16
|
import { createConfigFeature } from "../../config";
|
|
@@ -302,7 +303,7 @@ describe("POST /auth/reset-password", () => {
|
|
|
302
303
|
|
|
303
304
|
// Re-insert the captured row verbatim. Same userId, same version, same
|
|
304
305
|
// token still valid.
|
|
305
|
-
await
|
|
306
|
+
await seedRow(stack.db, userTable, userRow);
|
|
306
307
|
|
|
307
308
|
const secondAttempt = await post("/api/auth/reset-password", {
|
|
308
309
|
token,
|
|
@@ -357,7 +358,7 @@ describe("POST /auth/reset-password", () => {
|
|
|
357
358
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
358
359
|
|
|
359
360
|
const orphanId = "00000000-0000-4000-8000-0000000000ff";
|
|
360
|
-
await
|
|
361
|
+
await seedRow(stack.db, userTable, {
|
|
361
362
|
...donorRow,
|
|
362
363
|
id: orphanId,
|
|
363
364
|
email: "orphan@example.com",
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
createSystemUser,
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
SYSTEM_TENANT_ID,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
7
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
8
|
+
import { UserHandlers, UserQueries } from "../../user";
|
|
5
9
|
import { invalidCredentials } from "../errors";
|
|
6
10
|
import { hashPassword, verifyPassword } from "../password-hashing";
|
|
7
11
|
|
|
@@ -35,14 +39,10 @@ export const changePasswordWrite = defineWriteHandler({
|
|
|
35
39
|
|
|
36
40
|
const newHash = await hashPassword(event.payload.newPassword);
|
|
37
41
|
|
|
38
|
-
// The user aggregate is
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
// of version_conflict-ing against an empty one. Falls back to the session
|
|
43
|
-
// tenant when the lookup finds nothing (fresh/unknown stream).
|
|
44
|
-
const streamTenant = await getAggregateStreamTenant(ctx.db.raw, event.user.id, USER_FEATURE);
|
|
45
|
-
const writer = createSystemUser(streamTenant ?? event.user.tenantId);
|
|
42
|
+
// The user aggregate is systemStream (#497): its event stream lives on
|
|
43
|
+
// SYSTEM_TENANT_ID deterministically. Pre-#497 scattered streams need the
|
|
44
|
+
// one-time backfillUserStreamTenants migration (#762).
|
|
45
|
+
const writer = createSystemUser(SYSTEM_TENANT_ID);
|
|
46
46
|
|
|
47
47
|
// Apply via user feature's update handler — writeAs(system) satisfies
|
|
48
48
|
// the privileged-only write rule on passwordHash. Pass the current version
|
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
// 1. Redis check + burn (single-use enforcement)
|
|
6
6
|
// 2. Load user + deleted/missing/no-version guard
|
|
7
7
|
// 3. Optional idempotent short-circuit (verify-email when already done)
|
|
8
|
-
// 4.
|
|
9
|
-
// 5.
|
|
10
|
-
// 6. Release the burn on ANY non-success path so a legit retry isn't
|
|
8
|
+
// 4. Apply the handler-specific `changes` on the user's SYSTEM stream
|
|
9
|
+
// 5. Release the burn on ANY non-success path so a legit retry isn't
|
|
11
10
|
// locked out by a stale marker
|
|
12
11
|
//
|
|
13
12
|
// The top-level `runConfirmTokenFlow` orchestrates and owns the
|
|
@@ -15,26 +14,26 @@
|
|
|
15
14
|
// burn (success, already-done) flips `committed = true`; everything
|
|
16
15
|
// else — including future branches a maintainer adds — releases
|
|
17
16
|
// automatically.
|
|
17
|
+
//
|
|
18
|
+
// The user aggregate is systemStream (#497): its event stream lives on
|
|
19
|
+
// SYSTEM_TENANT_ID deterministically, so the write targets exactly one
|
|
20
|
+
// stream. The former membership-probing (`tryWriteAcrossTenants`) existed
|
|
21
|
+
// for pre-#497 scattered streams; those need the one-time
|
|
22
|
+
// backfillUserStreamTenants migration (#762) — probing them stopped working
|
|
23
|
+
// the moment the executor's stream choke-point landed anyway.
|
|
18
24
|
|
|
19
25
|
import {
|
|
20
26
|
createSystemUser,
|
|
21
27
|
type HandlerContext,
|
|
22
28
|
type SessionUser,
|
|
23
29
|
SYSTEM_TENANT_ID,
|
|
24
|
-
type TenantId,
|
|
25
30
|
type WriteResult,
|
|
26
31
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
-
import {
|
|
28
|
-
InternalError,
|
|
29
|
-
type WriteFailure,
|
|
30
|
-
writeFailure,
|
|
31
|
-
} from "@cosmicdrift/kumiko-framework/errors";
|
|
32
|
-
import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
|
|
32
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
33
33
|
import type Redis from "ioredis";
|
|
34
|
-
import {
|
|
34
|
+
import { UserHandlers, UserQueries } from "../../user";
|
|
35
35
|
import type { AuthUserRow } from "../auth-user-row";
|
|
36
36
|
import { parseAuthUserRow } from "../auth-user-row";
|
|
37
|
-
import { orderTenantsByPreference } from "../stream-tenant";
|
|
38
37
|
import { burnToken, unburnToken } from "../token-burn-store";
|
|
39
38
|
|
|
40
39
|
export type ConfirmTokenFlowSpec<TSuccessData> = {
|
|
@@ -46,13 +45,11 @@ export type ConfirmTokenFlowSpec<TSuccessData> = {
|
|
|
46
45
|
// is misconfigured, not the caller's fault.
|
|
47
46
|
readonly redisRequiredMessage: string;
|
|
48
47
|
// Standard failure returned for every "token can't be consumed" path
|
|
49
|
-
// (bad state,
|
|
50
|
-
//
|
|
51
|
-
// branch fired.
|
|
48
|
+
// (bad state, version conflict on the stream). The route layer returns
|
|
49
|
+
// 422 with a uniform code so the caller can't tell which branch fired.
|
|
52
50
|
readonly invalidToken: () => ReturnType<typeof writeFailure>;
|
|
53
|
-
// Handler-specific payload for user:update. Runs once per token
|
|
54
|
-
//
|
|
55
|
-
// (password-reset hashes here).
|
|
51
|
+
// Handler-specific payload for user:update. Runs once per token. Can be
|
|
52
|
+
// async (password-reset hashes here).
|
|
56
53
|
readonly buildChanges: (me: AuthUserRow) => Promise<Record<string, unknown>>;
|
|
57
54
|
// Returned verbatim on a successful write.
|
|
58
55
|
readonly successData: TSuccessData;
|
|
@@ -96,20 +93,21 @@ export async function runConfirmTokenFlow<TSuccessData>(
|
|
|
96
93
|
return { isSuccess: true, data: spec.alreadyDone.data };
|
|
97
94
|
}
|
|
98
95
|
|
|
99
|
-
const tenantOrder = await resolveStreamTenants(ctx, systemUser, me);
|
|
100
|
-
if (tenantOrder.length === 0) return spec.invalidToken();
|
|
101
|
-
|
|
102
96
|
const changes = await spec.buildChanges(me);
|
|
103
|
-
const
|
|
104
|
-
|
|
97
|
+
const writeRes = await ctx.writeAs(systemUser, UserHandlers.update, {
|
|
98
|
+
id: me.id,
|
|
99
|
+
version: me.version,
|
|
100
|
+
changes,
|
|
101
|
+
});
|
|
102
|
+
if (writeRes.isSuccess) {
|
|
105
103
|
committed = true;
|
|
106
104
|
return { isSuccess: true, data: spec.successData };
|
|
107
105
|
}
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// denied)
|
|
111
|
-
if (
|
|
112
|
-
return
|
|
106
|
+
// version_conflict = concurrent modification (or an un-migrated pre-#497
|
|
107
|
+
// stream, see header) → token-level failure. Anything else (DB down,
|
|
108
|
+
// access denied) bubbles unchanged so ops sees the real failure class.
|
|
109
|
+
if (writeRes.error.code === "version_conflict") return spec.invalidToken();
|
|
110
|
+
return writeRes;
|
|
113
111
|
} finally {
|
|
114
112
|
// committed===false covers EVERY failure path — including branches a
|
|
115
113
|
// future maintainer adds without reading this file. The original
|
|
@@ -121,14 +119,13 @@ export async function runConfirmTokenFlow<TSuccessData>(
|
|
|
121
119
|
}
|
|
122
120
|
}
|
|
123
121
|
|
|
124
|
-
// --- Private helpers ------------------------------------------------------
|
|
125
|
-
|
|
126
122
|
// Fetches the user row via the privileged findForAuth query and validates
|
|
127
|
-
// it's usable for a write: not deleted, has a row.version (
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// Return type narrows `version` to `number` so the
|
|
131
|
-
// need a `?? 0` fallback — the guard lives here,
|
|
123
|
+
// it's usable for a write: not deleted, has a row.version >= 1 (a row
|
|
124
|
+
// without any event stream — e.g. inserted straight into read_users — must
|
|
125
|
+
// not be confirmable: the write would otherwise seed a fresh stream with a
|
|
126
|
+
// bare user.updated). Return type narrows `version` to `number` so the
|
|
127
|
+
// write-callsite doesn't need a `?? 0` fallback — the guard lives here,
|
|
128
|
+
// not at every callsite.
|
|
132
129
|
async function loadValidatedUser(
|
|
133
130
|
ctx: HandlerContext,
|
|
134
131
|
systemUser: SessionUser,
|
|
@@ -137,74 +134,6 @@ async function loadValidatedUser(
|
|
|
137
134
|
const me = parseAuthUserRow(
|
|
138
135
|
await ctx.queryAs(systemUser, UserQueries.findForAuth, { id: userId }),
|
|
139
136
|
);
|
|
140
|
-
if (!me || me.isDeleted || me.version === undefined) return null;
|
|
137
|
+
if (!me || me.isDeleted || me.version === undefined || me.version < 1) return null;
|
|
141
138
|
return { ...me, version: me.version };
|
|
142
139
|
}
|
|
143
|
-
|
|
144
|
-
// Loads the user's memberships and returns a prioritised tenant list, with the
|
|
145
|
-
// aggregate's real stream tenant recovered from the event log prepended.
|
|
146
|
-
//
|
|
147
|
-
// Empty only when the user has NO memberships AND no recoverable stream tenant
|
|
148
|
-
// — the caller treats that as invalid_token. A zero-membership systemScope user
|
|
149
|
-
// whose stream lives outside any membership (a platform operator seeded under a
|
|
150
|
-
// fixture/SYSTEM tenant) still resolves, because the stream-tenant lookup runs
|
|
151
|
-
// before the empty check rather than being short-circuited by it.
|
|
152
|
-
async function resolveStreamTenants(
|
|
153
|
-
ctx: HandlerContext,
|
|
154
|
-
systemUser: SessionUser,
|
|
155
|
-
me: AuthUserRow,
|
|
156
|
-
): Promise<readonly TenantId[]> {
|
|
157
|
-
const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
|
|
158
|
-
userId: me.id,
|
|
159
|
-
})) as Array<{ tenantId: TenantId }>; // @cast-boundary db-runner
|
|
160
|
-
const ordered = orderTenantsByPreference(memberships, me.lastActiveTenantId);
|
|
161
|
-
|
|
162
|
-
// The user aggregate is r.systemScope(): its event stream lives in whichever
|
|
163
|
-
// tenant the creating executor used, which need NOT be a membership tenant.
|
|
164
|
-
// A platform operator seeded under a fixture/platform tenant is the live case
|
|
165
|
-
// — its stream tenant is absent from `ordered`, so a membership-only search
|
|
166
|
-
// rejects every write and collapses to invalid_token. Recover the real stream
|
|
167
|
-
// tenant from the event log and try it first; memberships stay as fallback.
|
|
168
|
-
// Pulled BEFORE the empty-membership check so a zero-membership operator whose
|
|
169
|
-
// stream lives in SYSTEM_TENANT is recoverable instead of collapsing to
|
|
170
|
-
// invalid_token — mirrors change-password.write.ts's unconditional recovery.
|
|
171
|
-
const streamTenant = await getAggregateStreamTenant(ctx.db.raw, me.id, USER_FEATURE);
|
|
172
|
-
if (streamTenant && !ordered.includes(streamTenant)) {
|
|
173
|
-
return [streamTenant, ...ordered];
|
|
174
|
-
}
|
|
175
|
-
return ordered;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Discriminated result for the write-across-tenants loop.
|
|
179
|
-
// all_conflicts → every candidate rejected with version_conflict →
|
|
180
|
-
// token-level failure; caller returns invalidToken.
|
|
181
|
-
// hard_failure → a non-conflict error that should bubble unchanged
|
|
182
|
-
// (DB down, access denied, …); caller returns it as-is.
|
|
183
|
-
type TenantWriteResult =
|
|
184
|
-
| { isSuccess: true }
|
|
185
|
-
| { isSuccess: false; reason: "all_conflicts" }
|
|
186
|
-
| { isSuccess: false; reason: "hard_failure"; failure: WriteFailure };
|
|
187
|
-
|
|
188
|
-
// Attempts the update against each candidate stream. memberships-query
|
|
189
|
-
// has no deterministic ORDER BY, so the matching stream is discovered by
|
|
190
|
-
// attempt: version_conflict → try the next candidate, anything else →
|
|
191
|
-
// bubble immediately so ops sees the real failure class.
|
|
192
|
-
async function tryWriteAcrossTenants(
|
|
193
|
-
ctx: HandlerContext,
|
|
194
|
-
me: AuthUserRow & { version: number },
|
|
195
|
-
tenantOrder: readonly TenantId[],
|
|
196
|
-
changes: Record<string, unknown>,
|
|
197
|
-
): Promise<TenantWriteResult> {
|
|
198
|
-
for (const tenantId of tenantOrder) {
|
|
199
|
-
const writeRes = await ctx.writeAs(createSystemUser(tenantId), UserHandlers.update, {
|
|
200
|
-
id: me.id,
|
|
201
|
-
version: me.version,
|
|
202
|
-
changes,
|
|
203
|
-
});
|
|
204
|
-
if (writeRes.isSuccess) return { isSuccess: true };
|
|
205
|
-
if (writeRes.error.code !== "version_conflict") {
|
|
206
|
-
return { isSuccess: false, reason: "hard_failure", failure: writeRes };
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return { isSuccess: false, reason: "all_conflicts" };
|
|
210
|
-
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// InviteAcceptScreen is a public route — anon invite-links are the
|
|
2
|
+
// documented use case (no <SessionProvider> ancestor). Regression 632/1:
|
|
3
|
+
// useSession() throws without a provider, so any consumer mounting this
|
|
4
|
+
// screen on a public route outside SessionAuthGate crashed at runtime.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
createStaticLocaleResolver,
|
|
9
|
+
LocaleProvider,
|
|
10
|
+
PrimitivesProvider,
|
|
11
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
12
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
13
|
+
import { render, screen } from "@testing-library/react";
|
|
14
|
+
import { defaultTranslations } from "../../i18n";
|
|
15
|
+
import { InviteAcceptScreen } from "../invite-accept-screen";
|
|
16
|
+
|
|
17
|
+
const resolver = createStaticLocaleResolver({ locale: "de" });
|
|
18
|
+
|
|
19
|
+
function renderWithoutSessionProvider(token: string) {
|
|
20
|
+
return render(
|
|
21
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
22
|
+
<LocaleProvider resolver={resolver} fallbackBundles={[defaultTranslations]}>
|
|
23
|
+
<InviteAcceptScreen token={token} />
|
|
24
|
+
</LocaleProvider>
|
|
25
|
+
</PrimitivesProvider>,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("InviteAcceptScreen — no <SessionProvider> ancestor (632/1)", () => {
|
|
30
|
+
test("renders the anonymous accept-form instead of throwing", () => {
|
|
31
|
+
renderWithoutSessionProvider("tok-123");
|
|
32
|
+
expect(screen.getByText("Einladung annehmen")).toBeTruthy();
|
|
33
|
+
expect(screen.getByLabelText(/^Passwort/)).toBeTruthy();
|
|
34
|
+
// Anon default mode is "anon-existing" — email field is shown too.
|
|
35
|
+
expect(screen.getByLabelText(/^E-Mail/)).toBeTruthy();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("missing token still renders (no session access needed for that branch)", () => {
|
|
39
|
+
renderWithoutSessionProvider("");
|
|
40
|
+
expect(
|
|
41
|
+
screen.getByText("Der Einladungs-Link enthält keinen Token oder ist ungültig."),
|
|
42
|
+
).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { SidebarProvider } from "@cosmicdrift/kumiko-renderer-web";
|
|
3
|
-
import { screen } from "@testing-library/react";
|
|
3
|
+
import { screen, within } from "@testing-library/react";
|
|
4
4
|
import userEvent from "@testing-library/user-event";
|
|
5
5
|
import { UserMenu } from "../user-menu";
|
|
6
6
|
import { makeSessionApi, renderWithProviders } from "./test-utils";
|
|
@@ -31,6 +31,19 @@ describe("UserMenu", () => {
|
|
|
31
31
|
// Trim "" → leerer displayName → fallback auf email → erste 2 Chars
|
|
32
32
|
expect(screen.getByText("BO")).toBeTruthy();
|
|
33
33
|
});
|
|
34
|
+
test("no displayName → dropdown label shows the email exactly once (not duplicated)", async () => {
|
|
35
|
+
const user = userEvent.setup();
|
|
36
|
+
const session = makeSessionApi({
|
|
37
|
+
user: { id: "u1", email: "bob@example.com", displayName: "", globalRoles: [] },
|
|
38
|
+
});
|
|
39
|
+
renderWithProviders(<UserMenu />, { session });
|
|
40
|
+
await user.click(screen.getByRole("button", { name: /bob@example.com/ }));
|
|
41
|
+
const menu = within(screen.getByRole("menu"));
|
|
42
|
+
// getByText throws if the email appears more than once inside the
|
|
43
|
+
// dropdown panel — displayName falls back to the email, so without the
|
|
44
|
+
// hasName guard the second (redundant) email line would duplicate it.
|
|
45
|
+
expect(menu.getByText("bob@example.com")).toBeTruthy();
|
|
46
|
+
});
|
|
34
47
|
test("opens dropdown on click and shows logout button", async () => {
|
|
35
48
|
const user = userEvent.setup();
|
|
36
49
|
const session = makeSessionApi();
|
|
@@ -32,10 +32,9 @@ export function makeAuthGate(
|
|
|
32
32
|
return AuthGate;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* kein provider mehr sein. */
|
|
35
|
+
// SessionProvider + AuthGate as one gate, so a public gate in front (e.g.
|
|
36
|
+
// /calculator) doesn't mount the session bootstrap — createKumikoApp stacks
|
|
37
|
+
// providers outside all gates, so SessionProvider can't be a provider anymore.
|
|
39
38
|
export function makeSessionAuthGate(
|
|
40
39
|
LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
|
|
41
40
|
loginProps?: LoginScreenProps,
|