@cosmicdrift/kumiko-bundled-features 0.70.0 → 0.72.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/feature.ts +16 -0
- package/src/billing-foundation/feature.ts +5 -0
- package/src/delivery/feature.ts +5 -0
- package/src/feature-toggles/feature.ts +5 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +33 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.test.ts +11 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +4 -1
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -2
- package/src/sessions/__tests__/sessions.integration.test.ts +63 -1
- package/src/sessions/feature.ts +9 -0
- package/src/sessions/session-callbacks.ts +16 -0
- package/src/tags/handlers/assign-tag.write.ts +1 -1
- package/src/tags/web/__tests__/tag-section.test.tsx +83 -1
- package/src/tenant/feature.ts +5 -0
- package/src/user/feature.ts +5 -0
- package/src/user/schema/user.ts +9 -8
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +5 -4
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +96 -0
- package/src/user-data-rights/web/client-plugin.tsx +9 -1
- package/src/user-data-rights/web/index.ts +1 -0
- package/src/user-data-rights/web/public-deletion-gate.tsx +42 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +38 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.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>",
|
|
@@ -84,11 +84,11 @@
|
|
|
84
84
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
88
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
89
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
87
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.72.0",
|
|
88
|
+
"@cosmicdrift/kumiko-framework": "0.72.0",
|
|
89
|
+
"@cosmicdrift/kumiko-headless": "0.72.0",
|
|
90
|
+
"@cosmicdrift/kumiko-renderer": "0.72.0",
|
|
91
|
+
"@cosmicdrift/kumiko-renderer-web": "0.72.0",
|
|
92
92
|
"@mollie/api-client": "^4.5.0",
|
|
93
93
|
"@node-rs/argon2": "^2.0.2",
|
|
94
94
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -128,6 +128,22 @@ export function createAuthEmailPasswordFeature(
|
|
|
128
128
|
r.describe(
|
|
129
129
|
"Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows \u2014 password reset, email verification, magic-link self-signup, and tenant invite \u2014 are registered only when you pass their respective option objects (`passwordReset`, `emailVerification`, `signup`, `invite`) to `createAuthEmailPasswordFeature(opts)`. Each opt-in flow uses HMAC-signed or opaque-random tokens delivered via callback (e.g. `sendResetEmail`) so the feature stays transport-agnostic. Requires the `user` and `tenant` features, and declares `JWT_SECRET` (\u2265 32 chars) in `authEmailPasswordEnvSchema` so a missing secret surfaces at boot validation rather than on the first login attempt.",
|
|
130
130
|
);
|
|
131
|
+
r.uiHints({
|
|
132
|
+
displayLabel: "Auth \u00b7 Email + Password",
|
|
133
|
+
category: "identity",
|
|
134
|
+
recommended: true,
|
|
135
|
+
configurableOptions: [
|
|
136
|
+
{ key: "passwordReset", label: "Password-Reset-Flow", type: "boolean", default: true },
|
|
137
|
+
{
|
|
138
|
+
key: "emailVerification",
|
|
139
|
+
label: "Email-Verification-Flow",
|
|
140
|
+
type: "boolean",
|
|
141
|
+
default: true,
|
|
142
|
+
},
|
|
143
|
+
{ key: "signup", label: "Self-Signup-Flow", type: "boolean", default: false },
|
|
144
|
+
{ key: "invite", label: "Tenant-Invite-Flow", type: "boolean", default: false },
|
|
145
|
+
],
|
|
146
|
+
});
|
|
131
147
|
r.requires("user");
|
|
132
148
|
r.requires("tenant");
|
|
133
149
|
r.envSchema(authEmailPasswordEnvSchema);
|
|
@@ -74,6 +74,11 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
|
|
|
74
74
|
r.describe(
|
|
75
75
|
"Plugin host for subscription billing \u2014 manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookHandler` factory for the `/api/subscription/webhook/:providerName` route. Low-level building block \u2014 use `subscription-stripe` or `subscription-mollie` unless you are writing a new payment provider.",
|
|
76
76
|
);
|
|
77
|
+
r.uiHints({
|
|
78
|
+
displayLabel: "Billing \u00b7 Foundation",
|
|
79
|
+
category: "billing",
|
|
80
|
+
recommended: false,
|
|
81
|
+
});
|
|
77
82
|
// 5 fine-grained domain-events. Alle 5 nutzen denselben payload-
|
|
78
83
|
// shape (= subscription-state-snapshot); der event-type taggt was
|
|
79
84
|
// passiert ist. Future-consumer (billing-history, accounting)
|
package/src/delivery/feature.ts
CHANGED
|
@@ -18,6 +18,11 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
18
18
|
r.describe(
|
|
19
19
|
"The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, logs every attempt to `read_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
|
|
20
20
|
);
|
|
21
|
+
r.uiHints({
|
|
22
|
+
displayLabel: "Notifications \u00b7 Dispatch Core",
|
|
23
|
+
category: "notifications",
|
|
24
|
+
recommended: true,
|
|
25
|
+
});
|
|
21
26
|
r.systemScope();
|
|
22
27
|
// Backing table: the (tenant,user,type,channel) uniqueIndex lives only on
|
|
23
28
|
// the physical table, not on the entity fields, so the generator would
|
|
@@ -40,6 +40,11 @@ export function createFeatureTogglesFeature(
|
|
|
40
40
|
r.describe(
|
|
41
41
|
'Persists per-feature enabled/disabled state in the `read_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "per-instance"` syncs the snapshot across instances whenever a `toggle-set` event is appended.',
|
|
42
42
|
);
|
|
43
|
+
r.uiHints({
|
|
44
|
+
displayLabel: "Feature Toggles · Operator Switches",
|
|
45
|
+
category: "operations",
|
|
46
|
+
recommended: false,
|
|
47
|
+
});
|
|
43
48
|
r.systemScope();
|
|
44
49
|
|
|
45
50
|
// Toggle-change domain event. The event ends up in the events-table
|
|
@@ -32,6 +32,7 @@ import { ConfigHandlers } from "../../config/constants";
|
|
|
32
32
|
import { createConfigAccessorFactory } from "../../config/feature";
|
|
33
33
|
import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
|
|
34
34
|
import { configValuesTable } from "../../config/table";
|
|
35
|
+
import { clearInbox, getInbox, mailTransportInMemoryFeature } from "../../mail-transport-inmemory";
|
|
35
36
|
import { mailTransportSmtpFeature, SMTP_PASSWORD } from "../../mail-transport-smtp";
|
|
36
37
|
import { createSecretsContext, createSecretsFeature, tenantSecretsTable } from "../../secrets";
|
|
37
38
|
import { createTenantFeature } from "../../tenant/feature";
|
|
@@ -58,6 +59,22 @@ const testProbeFeature = defineFeature("mail-test", (r) => {
|
|
|
58
59
|
},
|
|
59
60
|
}),
|
|
60
61
|
);
|
|
62
|
+
r.writeHandler(
|
|
63
|
+
defineWriteHandler({
|
|
64
|
+
name: "send",
|
|
65
|
+
schema: z.object({ to: z.string(), subject: z.string(), html: z.string() }),
|
|
66
|
+
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
67
|
+
handler: async (event, ctx) => {
|
|
68
|
+
const transport = await createTransportForTenant(
|
|
69
|
+
ctx,
|
|
70
|
+
event.user.tenantId,
|
|
71
|
+
"mail-test:write:send",
|
|
72
|
+
);
|
|
73
|
+
await transport.send(event.payload);
|
|
74
|
+
return { isSuccess: true, data: {} };
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
61
78
|
});
|
|
62
79
|
|
|
63
80
|
// --- Setup ---
|
|
@@ -91,6 +108,7 @@ beforeAll(async () => {
|
|
|
91
108
|
createSecretsFeature(),
|
|
92
109
|
mailFoundationFeature,
|
|
93
110
|
mailTransportSmtpFeature,
|
|
111
|
+
mailTransportInMemoryFeature,
|
|
94
112
|
testProbeFeature,
|
|
95
113
|
],
|
|
96
114
|
masterKeyProvider: providerRef,
|
|
@@ -251,3 +269,18 @@ describe("scenario 3: tenant isolation", () => {
|
|
|
251
269
|
expect(b["hasSend"]).toBe(true);
|
|
252
270
|
});
|
|
253
271
|
});
|
|
272
|
+
|
|
273
|
+
// --- Scenario 4: the in-memory transport plugin actually delivers ---
|
|
274
|
+
|
|
275
|
+
describe("scenario 4: in-memory transport dispatch", () => {
|
|
276
|
+
test("provider=inmemory → a sent mail lands in that tenant's inbox", async () => {
|
|
277
|
+
const admin = adminFor(406);
|
|
278
|
+
clearInbox(admin.tenantId);
|
|
279
|
+
await setConfig(admin, "mail-foundation:config:provider", "inmemory");
|
|
280
|
+
|
|
281
|
+
const message = { to: "user@acme.test", subject: "Hi", html: "<p>hello</p>" };
|
|
282
|
+
await stack.http.writeOk("mail-test:write:send", message, admin);
|
|
283
|
+
|
|
284
|
+
expect(getInbox(admin.tenantId)).toEqual([message]);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -71,6 +71,17 @@ describe("rate-limiting feature — status query", () => {
|
|
|
71
71
|
expect(status.remaining).toBe(2);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
test("blocks with 429 once the bucket is drained", async () => {
|
|
75
|
+
// The status query only *reports* state; this proves enforcement —
|
|
76
|
+
// the L3 hook actually rejects traffic over the limit, not just counts.
|
|
77
|
+
for (let i = 0; i < 5; i++) {
|
|
78
|
+
const ok = await stack.http.query("rl-probe:query:ping", {}, admin);
|
|
79
|
+
expect(ok.status).toBe(200);
|
|
80
|
+
}
|
|
81
|
+
const blocked = await stack.http.query("rl-probe:query:ping", {}, admin);
|
|
82
|
+
expect(blocked.status).toBe(429);
|
|
83
|
+
});
|
|
84
|
+
|
|
74
85
|
test("status access requires Admin/SystemAdmin", async () => {
|
|
75
86
|
const guest = TestUsers.user;
|
|
76
87
|
const res = await stack.http.query(
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
unsafeCreateEntityTable,
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
17
17
|
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
18
|
+
import { createUserFeature } from "../../user/feature";
|
|
19
|
+
import { userEntity } from "../../user/schema/user";
|
|
18
20
|
import { createSessionsFeature } from "../feature";
|
|
19
21
|
import { cleanupJob } from "../handlers/cleanup.job";
|
|
20
22
|
import { userSessionEntity, userSessionTable } from "../schema/user-session";
|
|
@@ -38,9 +40,10 @@ let stack: TestStack;
|
|
|
38
40
|
|
|
39
41
|
beforeAll(async () => {
|
|
40
42
|
stack = await setupTestStack({
|
|
41
|
-
features: [createSessionsFeature()],
|
|
43
|
+
features: [createSessionsFeature(), createUserFeature()],
|
|
42
44
|
});
|
|
43
45
|
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
46
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
44
47
|
});
|
|
45
48
|
|
|
46
49
|
afterAll(async () => {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
unsafeCreateEntityTable,
|
|
20
20
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
21
|
import { Temporal } from "temporal-polyfill";
|
|
22
|
+
import { createUserFeature } from "../../user/feature";
|
|
22
23
|
import { createSessionsFeature } from "../feature";
|
|
23
24
|
import { userSessionEntity, userSessionTable } from "../schema/user-session";
|
|
24
25
|
|
|
@@ -75,14 +76,14 @@ async function insertRevokedSession(db: DbConnection): Promise<void> {
|
|
|
75
76
|
|
|
76
77
|
describe("sessions / read_user_sessions survives projection rebuild", () => {
|
|
77
78
|
test("is NOT registered as a rebuildable implicit projection", () => {
|
|
78
|
-
const registry = createRegistry([createSessionsFeature()]);
|
|
79
|
+
const registry = createRegistry([createSessionsFeature(), createUserFeature()]);
|
|
79
80
|
expect(registry.getAllProjections().has(IMPLICIT_PROJECTION)).toBe(false);
|
|
80
81
|
});
|
|
81
82
|
|
|
82
83
|
test("direct-written rows (incl. revoked state) survive a rebuild", async () => {
|
|
83
84
|
await insertRevokedSession(createTenantDb(testDb.db, TENANT));
|
|
84
85
|
|
|
85
|
-
const registry = createRegistry([createSessionsFeature()]);
|
|
86
|
+
const registry = createRegistry([createSessionsFeature(), createUserFeature()]);
|
|
86
87
|
// Pre-fix: the implicit projection exists → rebuild swaps an empty shadow
|
|
87
88
|
// → rows wiped. Post-fix: absent → no rebuild → rows untouched. Either way
|
|
88
89
|
// a regression (re-adding r.entity) makes this fail.
|
|
@@ -21,7 +21,7 @@ import { createTenantFeature } from "../../tenant";
|
|
|
21
21
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
22
22
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
23
23
|
import { createUserFeature } from "../../user/feature";
|
|
24
|
-
import { userEntity, userTable } from "../../user/schema/user";
|
|
24
|
+
import { USER_STATUS, userEntity, userTable } from "../../user/schema/user";
|
|
25
25
|
import { SessionHandlers, SessionQueries } from "../constants";
|
|
26
26
|
import { createSessionsFeature } from "../feature";
|
|
27
27
|
import { userSessionEntity, userSessionTable } from "../schema/user-session";
|
|
@@ -455,3 +455,65 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
455
455
|
expect(body.data[0]?.id).toBe(aliceAsAdmin.sid);
|
|
456
456
|
});
|
|
457
457
|
});
|
|
458
|
+
|
|
459
|
+
// Defense-in-depth: the sessionChecker refuses a live sid once the user it
|
|
460
|
+
// belongs to is locked, independent of whether session-revoke ran. Each case
|
|
461
|
+
// logs in WHILE active (login itself blocks locked users) and then flips the
|
|
462
|
+
// status, mirroring "user got restricted while a session was open".
|
|
463
|
+
describe("sessions feature — locked accounts blocked on a live session", () => {
|
|
464
|
+
test("active user passes — the gate leaves the happy path untouched", async () => {
|
|
465
|
+
await h.seedUser("active@example.com", "pw-long-enough");
|
|
466
|
+
const { token } = await h.login("active@example.com", "pw-long-enough");
|
|
467
|
+
|
|
468
|
+
const res = await h.authedPost("/api/query", token, {
|
|
469
|
+
type: "user:query:user:me",
|
|
470
|
+
payload: {},
|
|
471
|
+
});
|
|
472
|
+
expect(res.status).toBe(200);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("restricted after login → 401 reason=blocked", async () => {
|
|
476
|
+
const { userId } = await h.seedUser("restrict@example.com", "pw-long-enough");
|
|
477
|
+
const { token } = await h.login("restrict@example.com", "pw-long-enough");
|
|
478
|
+
await updateMany(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: userId });
|
|
479
|
+
|
|
480
|
+
const res = await h.authedPost("/api/query", token, {
|
|
481
|
+
type: "user:query:user:me",
|
|
482
|
+
payload: {},
|
|
483
|
+
});
|
|
484
|
+
expect(res.status).toBe(401);
|
|
485
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
486
|
+
expect(body.error?.details?.reason).toBe("blocked");
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("deleted after login → 401 reason=blocked", async () => {
|
|
490
|
+
const { userId } = await h.seedUser("gone@example.com", "pw-long-enough");
|
|
491
|
+
const { token } = await h.login("gone@example.com", "pw-long-enough");
|
|
492
|
+
await updateMany(stack.db, userTable, { status: USER_STATUS.Deleted }, { id: userId });
|
|
493
|
+
|
|
494
|
+
const res = await h.authedPost("/api/query", token, {
|
|
495
|
+
type: "user:query:user:me",
|
|
496
|
+
payload: {},
|
|
497
|
+
});
|
|
498
|
+
expect(res.status).toBe(401);
|
|
499
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
500
|
+
expect(body.error?.details?.reason).toBe("blocked");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("deletionRequested keeps its session live — reversible grace period", async () => {
|
|
504
|
+
const { userId } = await h.seedUser("leaving@example.com", "pw-long-enough");
|
|
505
|
+
const { token } = await h.login("leaving@example.com", "pw-long-enough");
|
|
506
|
+
await updateMany(
|
|
507
|
+
stack.db,
|
|
508
|
+
userTable,
|
|
509
|
+
{ status: USER_STATUS.DeletionRequested },
|
|
510
|
+
{ id: userId },
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
const res = await h.authedPost("/api/query", token, {
|
|
514
|
+
type: "user:query:user:me",
|
|
515
|
+
payload: {},
|
|
516
|
+
});
|
|
517
|
+
expect(res.status).toBe(200);
|
|
518
|
+
});
|
|
519
|
+
});
|
package/src/sessions/feature.ts
CHANGED
|
@@ -43,6 +43,15 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
43
43
|
r.describe(
|
|
44
44
|
"Tracks signed-in clients in the `read_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
|
|
45
45
|
);
|
|
46
|
+
r.uiHints({
|
|
47
|
+
displayLabel: "Sessions · Server-side Logout",
|
|
48
|
+
category: "identity",
|
|
49
|
+
recommended: false,
|
|
50
|
+
});
|
|
51
|
+
// sessionChecker reads read_users on every authenticated request (status
|
|
52
|
+
// gate for locked accounts) — make that a boot-time dependency so a
|
|
53
|
+
// sessions-without-user wiring fails validateBoot instead of 500ing live.
|
|
54
|
+
r.requires("user");
|
|
46
55
|
// read_user_sessions is a hot-path direct-write store: sessionCreator
|
|
47
56
|
// inserts and the revoke handlers update rows WITHOUT emitting lifecycle
|
|
48
57
|
// events (the row columns ARE the audit trail). Registering it as
|
|
@@ -10,9 +10,18 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
|
10
10
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
11
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
12
12
|
import { Temporal } from "temporal-polyfill";
|
|
13
|
+
import { USER_STATUS, userTable } from "../user";
|
|
13
14
|
import { DEFAULT_SESSION_EXPIRY_MS } from "./constants";
|
|
14
15
|
import { userSessionTable } from "./schema/user-session";
|
|
15
16
|
|
|
17
|
+
// Locked accounts whose live sessions must be refused. deletionRequested is
|
|
18
|
+
// intentionally absent — it's a reversible grace period and the user needs
|
|
19
|
+
// their session to reach cancel-deletion.
|
|
20
|
+
const BLOCKED_STATUSES: ReadonlySet<string> = new Set([
|
|
21
|
+
USER_STATUS.Restricted,
|
|
22
|
+
USER_STATUS.Deleted,
|
|
23
|
+
]);
|
|
24
|
+
|
|
16
25
|
// Why the callbacks live at the raw-DB level rather than going through the
|
|
17
26
|
// dispatcher: session-create/revoke/check run on the hot path of every
|
|
18
27
|
// login and every request. The (createdAt/revokedAt/ip/userAgent) columns
|
|
@@ -90,6 +99,13 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
|
|
|
90
99
|
if (row.expiresAt.epochMilliseconds <= Temporal.Now.instant().epochMilliseconds) {
|
|
91
100
|
return "expired";
|
|
92
101
|
}
|
|
102
|
+
// Defense-in-depth: status flips (Art. 18 restrict, forget) revoke
|
|
103
|
+
// sessions, but a missed revoke must not keep a locked account alive on
|
|
104
|
+
// a stale sid. Fail-OPEN on a lookup miss — this is the second layer,
|
|
105
|
+
// revocation is primary; never turn a user-row miss into a global
|
|
106
|
+
// lockout. (+1 PK read on read_users per authenticated request.)
|
|
107
|
+
const user = await fetchOne<{ status: string }>(db, userTable, { id: expectedUserId });
|
|
108
|
+
if (user && BLOCKED_STATUSES.has(user.status)) return "blocked";
|
|
93
109
|
return "live";
|
|
94
110
|
},
|
|
95
111
|
|
|
@@ -40,7 +40,7 @@ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const restored = await tagAssignmentExecutor.restore({ id }, event.user, ctx.db);
|
|
43
|
-
if (restored.isSuccess) return
|
|
43
|
+
if (restored.isSuccess) return { isSuccess: true as const, data: { id } };
|
|
44
44
|
if (restored.error.code !== "not_found") return restored;
|
|
45
45
|
|
|
46
46
|
const tag = await tagExecutor.detail({ id: payload.tagId }, event.user, ctx.db);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
createStaticLocaleResolver,
|
|
4
4
|
LocaleProvider,
|
|
@@ -17,6 +17,13 @@ type AssignmentRow = { tagId: string; entityType: string; entityId: string };
|
|
|
17
17
|
let catalogRows: readonly TagRow[] = [];
|
|
18
18
|
let assignmentRows: readonly AssignmentRow[] = [];
|
|
19
19
|
|
|
20
|
+
// Each test sets its own rows; reset so a forgotten setup can't inherit the
|
|
21
|
+
// previous test's data (order-dependent shared state).
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
catalogRows = [];
|
|
24
|
+
assignmentRows = [];
|
|
25
|
+
});
|
|
26
|
+
|
|
20
27
|
const dispatchSpy = mock(async (type: string) =>
|
|
21
28
|
type === TagsHandlers.createTag
|
|
22
29
|
? { isSuccess: true, data: { id: "tag-new" } }
|
|
@@ -45,6 +52,46 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
|
45
52
|
);
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
// The real combobox is cmdk + Radix — its popover is e2e/primitive-test
|
|
56
|
+
// territory (see note above). To pin the onSelectionChange → assign/remove
|
|
57
|
+
// wiring we swap in a headless stub that renders one toggle button per option
|
|
58
|
+
// and fires onChange with the toggled selection — same contract, no popover.
|
|
59
|
+
const StubInput: typeof defaultPrimitives.Input = (props) => {
|
|
60
|
+
if (props.kind === "combobox" && props.multiple === true) {
|
|
61
|
+
const value = props.value;
|
|
62
|
+
return (
|
|
63
|
+
<div data-testid="stub-combobox">
|
|
64
|
+
{props.options.map((o) => {
|
|
65
|
+
const selected = value.includes(o.value);
|
|
66
|
+
return (
|
|
67
|
+
<button
|
|
68
|
+
key={o.value}
|
|
69
|
+
type="button"
|
|
70
|
+
data-testid={`tag-opt-${o.value}`}
|
|
71
|
+
onClick={() =>
|
|
72
|
+
props.onChange(selected ? value.filter((v) => v !== o.value) : [...value, o.value])
|
|
73
|
+
}
|
|
74
|
+
>
|
|
75
|
+
{o.label}
|
|
76
|
+
</button>
|
|
77
|
+
);
|
|
78
|
+
})}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return <input data-testid={`stub-${props.id}`} />;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function StubComboboxWrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
86
|
+
return (
|
|
87
|
+
<LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
|
|
88
|
+
<PrimitivesProvider value={{ ...defaultPrimitives, Input: StubInput }}>
|
|
89
|
+
{children}
|
|
90
|
+
</PrimitivesProvider>
|
|
91
|
+
</LocaleProvider>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
48
95
|
// The combobox's assign/remove toggle drives onChange with the full new
|
|
49
96
|
// selection; the component diffs it against the current tags via this helper.
|
|
50
97
|
// Popover interaction itself (cmdk + Radix in jsdom) is covered by the
|
|
@@ -113,6 +160,41 @@ describe("TagSection", () => {
|
|
|
113
160
|
);
|
|
114
161
|
});
|
|
115
162
|
|
|
163
|
+
test("#524/3: selection change dispatches assign for additions, remove for removals", async () => {
|
|
164
|
+
catalogRows = [
|
|
165
|
+
{ id: "t1", name: "important" },
|
|
166
|
+
{ id: "t2", name: "project-x" },
|
|
167
|
+
];
|
|
168
|
+
assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "note-1" }];
|
|
169
|
+
dispatchSpy.mockClear();
|
|
170
|
+
|
|
171
|
+
render(
|
|
172
|
+
<StubComboboxWrapper>
|
|
173
|
+
<TagSection entityName="note" entityId="note-1" />
|
|
174
|
+
</StubComboboxWrapper>,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// t2 is unselected → toggling it on adds it → assign-tag with t2
|
|
178
|
+
fireEvent.click(screen.getByTestId("tag-opt-t2"));
|
|
179
|
+
await waitFor(() =>
|
|
180
|
+
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.assignTag, {
|
|
181
|
+
tagId: "t2",
|
|
182
|
+
entityType: "note",
|
|
183
|
+
entityId: "note-1",
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// t1 is selected → toggling it off removes it → remove-tag with t1
|
|
188
|
+
fireEvent.click(screen.getByTestId("tag-opt-t1"));
|
|
189
|
+
await waitFor(() =>
|
|
190
|
+
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.removeTag, {
|
|
191
|
+
tagId: "t1",
|
|
192
|
+
entityType: "note",
|
|
193
|
+
entityId: "note-1",
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
116
198
|
test("create-mode (no entityId yet) shows the save-first hint instead of the manager", () => {
|
|
117
199
|
render(
|
|
118
200
|
<Wrapper>
|
package/src/tenant/feature.ts
CHANGED
|
@@ -32,6 +32,11 @@ export function createTenantFeature(): FeatureDefinition {
|
|
|
32
32
|
r.describe(
|
|
33
33
|
"Registers the three core multi-tenancy entities \u2014 `tenant`, `tenant-membership`, and `tenant-invitation` (DB tables `read_tenants`, `read_tenant_memberships`, and `read_tenant_invitations`) \u2014 along with write handlers for create/update/disable/enable/addMember/removeMember/updateMemberRoles and the matching queries. It also declares a set of per-tenant config keys (companyName, timezone, locale, SMTP credentials) and system-only keys (priceModel, maxUsers) via `r.config({ keys: { ... } })`. Use this feature in every multi-tenant app; membership resolution and invitation flows depend on it, and `auth-email-password` requires it.",
|
|
34
34
|
);
|
|
35
|
+
r.uiHints({
|
|
36
|
+
displayLabel: "Multi-Tenant Core",
|
|
37
|
+
category: "identity",
|
|
38
|
+
recommended: true,
|
|
39
|
+
});
|
|
35
40
|
r.systemScope();
|
|
36
41
|
r.requires("config");
|
|
37
42
|
r.entity("tenant", tenantEntity);
|
package/src/user/feature.ts
CHANGED
|
@@ -15,6 +15,11 @@ export function createUserFeature(): FeatureDefinition {
|
|
|
15
15
|
r.describe(
|
|
16
16
|
"Manages the cross-tenant user identity: the `read_users` table holds each user's email, `displayName`, global `roles`, `emailVerified` flag, and lifecycle `status` (active / restricted / deletionRequested / deleted). Because users exist above any individual tenant, the feature runs with `r.systemScope()` \u2014 membership and tenant-specific roles live in the `tenant` feature instead. Add this feature whenever your app needs a persistent, tenant-agnostic user record that auth and GDPR pipelines can reference.",
|
|
17
17
|
);
|
|
18
|
+
r.uiHints({
|
|
19
|
+
displayLabel: "User Identity",
|
|
20
|
+
category: "identity",
|
|
21
|
+
recommended: true,
|
|
22
|
+
});
|
|
18
23
|
r.systemScope();
|
|
19
24
|
r.entity("user", userEntity);
|
|
20
25
|
|
package/src/user/schema/user.ts
CHANGED
|
@@ -113,15 +113,16 @@ export const userEntity = createEntity({
|
|
|
113
113
|
|
|
114
114
|
// S2.U1: User-Lifecycle-Status für user-data-rights (Sprint 2).
|
|
115
115
|
// - "active": Normaler State, alle Operationen erlaubt
|
|
116
|
-
// - "restricted": Art. 18 Restriction —
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
116
|
+
// - "restricted": Art. 18 Restriction — Login blockiert + jede
|
|
117
|
+
// Live-Session wird vom sessionChecker abgewiesen
|
|
118
|
+
// ("blocked"). Recovery via lift-restriction
|
|
119
|
+
// (openToAll, session-unabhängig).
|
|
120
|
+
// - "deletionRequested": delete-account aufgerufen, gracePeriodEnd gesetzt,
|
|
121
|
+
// Login blockiert. Bestehende Session bleibt LIVE
|
|
122
|
+
// (reversibel) — User kann via cancel-deletion
|
|
123
|
+
// zurück auf "active".
|
|
123
124
|
// - "deleted": Forget executed nach Grace, Row anonymisiert via
|
|
124
|
-
// softDelete.
|
|
125
|
+
// softDelete. Login blockiert + Session "blocked".
|
|
125
126
|
//
|
|
126
127
|
// Schreibrecht privileged: nur die request-deletion / restrict / lift /
|
|
127
128
|
// execute-forget-Handler (alle SYSTEM-context) duerfen status flippen.
|
package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts
CHANGED
|
@@ -188,12 +188,13 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
|
|
|
188
188
|
|
|
189
189
|
const after = (await selectMany(stack.db, userTable, { id: created.id })) as Array<{
|
|
190
190
|
status: string;
|
|
191
|
-
gracePeriodEnd:
|
|
191
|
+
gracePeriodEnd: typeof gracePeriodEnd | null;
|
|
192
192
|
}>;
|
|
193
193
|
expect(after[0]?.status).toBe(USER_STATUS.DeletionRequested);
|
|
194
|
-
// gracePeriodEnd ueberlebt
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
// gracePeriodEnd ueberlebt den Replay WERT-genau, nicht nur non-null: ein
|
|
195
|
+
// Timezone-/Roundtrip-Fehler liefert non-null aber den falschen Instant.
|
|
196
|
+
// epoch-ms toleriert die DB-Präzision (µs) ohne sub-ms-Drift zu prüfen.
|
|
197
|
+
expect(after[0]?.gracePeriodEnd?.epochMilliseconds).toBe(gracePeriodEnd.epochMilliseconds);
|
|
197
198
|
});
|
|
198
199
|
|
|
199
200
|
// Ehrlicher Spiegel zum Forward-Test: Bestandsdaten, deren Status der ALTE
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Gate path-routing: requestPath → request screen, confirmPath → confirm
|
|
2
|
+
// screen, sonst durch zu children. Bewusst SYNCHRON (kein fireEvent/waitFor) —
|
|
3
|
+
// der #457-CI-Flake trifft nur await-Assertions auf dem geteilten happy-dom-
|
|
4
|
+
// document; ein Render + Sync-Assert im selben Tick ist nicht exponiert.
|
|
5
|
+
// Provider-Wrapper lokal (Dependency-Richtung renderer-web → bundled-features
|
|
6
|
+
// verbietet test-utils-Import).
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
10
|
+
import {
|
|
11
|
+
createStaticLocaleResolver,
|
|
12
|
+
DispatcherProvider,
|
|
13
|
+
kumikoDefaultTranslations,
|
|
14
|
+
LocaleProvider,
|
|
15
|
+
PrimitivesProvider,
|
|
16
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
17
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
18
|
+
import { render, within } from "@testing-library/react";
|
|
19
|
+
import type { ReactNode } from "react";
|
|
20
|
+
import { defaultTranslations } from "../i18n";
|
|
21
|
+
import { makePublicDeletionGate } from "../public-deletion-gate";
|
|
22
|
+
|
|
23
|
+
const resolver = createStaticLocaleResolver({ locale: "de" });
|
|
24
|
+
const stubDispatcher = {
|
|
25
|
+
write: async () => ({ isSuccess: true, data: {} }),
|
|
26
|
+
} as unknown as Dispatcher;
|
|
27
|
+
|
|
28
|
+
const ROUTES = { requestPath: "/account/delete", confirmPath: "/account/delete/confirm" };
|
|
29
|
+
|
|
30
|
+
function renderGate(path: string, gate: ReactNode): ReturnType<typeof within> {
|
|
31
|
+
window.history.replaceState({}, "", path);
|
|
32
|
+
const { container } = render(
|
|
33
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
34
|
+
<LocaleProvider
|
|
35
|
+
resolver={resolver}
|
|
36
|
+
fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
|
|
37
|
+
>
|
|
38
|
+
<DispatcherProvider dispatcher={stubDispatcher}>{gate}</DispatcherProvider>
|
|
39
|
+
</LocaleProvider>
|
|
40
|
+
</PrimitivesProvider>,
|
|
41
|
+
);
|
|
42
|
+
return within(container);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("makePublicDeletionGate", () => {
|
|
46
|
+
test("requestPath → request screen, children short-circuited", () => {
|
|
47
|
+
const Gate = makePublicDeletionGate(ROUTES);
|
|
48
|
+
const ui = renderGate(
|
|
49
|
+
"/account/delete",
|
|
50
|
+
<Gate>
|
|
51
|
+
<div data-testid="app">APP</div>
|
|
52
|
+
</Gate>,
|
|
53
|
+
);
|
|
54
|
+
expect(ui.getByText(/beantragen/)).toBeTruthy();
|
|
55
|
+
expect(ui.queryByTestId("app")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("confirmPath → confirm screen, children short-circuited", () => {
|
|
59
|
+
const Gate = makePublicDeletionGate(ROUTES);
|
|
60
|
+
const ui = renderGate(
|
|
61
|
+
"/account/delete/confirm",
|
|
62
|
+
<Gate>
|
|
63
|
+
<div data-testid="app">APP</div>
|
|
64
|
+
</Gate>,
|
|
65
|
+
);
|
|
66
|
+
expect(ui.getByText(/bestätigen/)).toBeTruthy();
|
|
67
|
+
expect(ui.queryByTestId("app")).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("other path → children pass through, no deletion screen", () => {
|
|
71
|
+
const Gate = makePublicDeletionGate(ROUTES);
|
|
72
|
+
const ui = renderGate(
|
|
73
|
+
"/dashboard",
|
|
74
|
+
<Gate>
|
|
75
|
+
<div data-testid="app">APP</div>
|
|
76
|
+
</Gate>,
|
|
77
|
+
);
|
|
78
|
+
expect(ui.getByTestId("app")).toBeTruthy();
|
|
79
|
+
expect(ui.queryByText(/beantragen/)).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("custom shell wraps the matched screen", () => {
|
|
83
|
+
const Gate = makePublicDeletionGate({
|
|
84
|
+
...ROUTES,
|
|
85
|
+
shell: (screen) => <div data-testid="shell">{screen}</div>,
|
|
86
|
+
});
|
|
87
|
+
const ui = renderGate(
|
|
88
|
+
"/account/delete",
|
|
89
|
+
<Gate>
|
|
90
|
+
<span>APP</span>
|
|
91
|
+
</Gate>,
|
|
92
|
+
);
|
|
93
|
+
const shell = ui.getByTestId("shell");
|
|
94
|
+
expect(within(shell).getByText(/beantragen/)).toBeTruthy();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -11,20 +11,28 @@ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
|
11
11
|
import { PRIVACY_CENTER_SCREEN_ID, USER_DATA_RIGHTS_FEATURE } from "../constants";
|
|
12
12
|
import { defaultTranslations } from "./i18n";
|
|
13
13
|
import { PrivacyCenterScreen } from "./privacy-center-screen";
|
|
14
|
+
import { makePublicDeletionGate, type PublicDeletionRoutes } from "./public-deletion-gate";
|
|
14
15
|
|
|
15
16
|
export type UserDataRightsClientOptions = {
|
|
16
17
|
/** Key-weise Overrides über die Default-Bundles (de/en). */
|
|
17
18
|
readonly translations?: TranslationsByLocale;
|
|
19
|
+
/** Wenn gesetzt: registriert die anonymen (login-freien) Lösch-Screens als
|
|
20
|
+
* Gate auf den angegebenen Pfaden. Weglassen → nur der eingeloggte
|
|
21
|
+
* privacy-center-Screen. Den Client VOR dem Auth-Client listen, sonst
|
|
22
|
+
* landet der anonyme Besucher auf der Login-Maske. */
|
|
23
|
+
readonly publicDeletion?: PublicDeletionRoutes;
|
|
18
24
|
};
|
|
19
25
|
|
|
20
26
|
export function userDataRightsClient(
|
|
21
27
|
options?: UserDataRightsClientOptions,
|
|
22
28
|
): ClientFeatureDefinition {
|
|
23
|
-
|
|
29
|
+
const base: ClientFeatureDefinition = {
|
|
24
30
|
name: USER_DATA_RIGHTS_FEATURE,
|
|
25
31
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
26
32
|
components: {
|
|
27
33
|
[PRIVACY_CENTER_SCREEN_ID]: PrivacyCenterScreen,
|
|
28
34
|
},
|
|
29
35
|
};
|
|
36
|
+
if (options?.publicDeletion === undefined) return base;
|
|
37
|
+
return { ...base, gates: [makePublicDeletionGate(options.publicDeletion)] };
|
|
30
38
|
}
|
|
@@ -10,5 +10,6 @@ export type { ConfirmAccountDeletionScreenProps } from "./confirm-deletion-scree
|
|
|
10
10
|
export { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen";
|
|
11
11
|
export { defaultTranslations } from "./i18n";
|
|
12
12
|
export { formatDate, PrivacyCenterScreen } from "./privacy-center-screen";
|
|
13
|
+
export { makePublicDeletionGate, type PublicDeletionRoutes } from "./public-deletion-gate";
|
|
13
14
|
export type { RequestAccountDeletionScreenProps } from "./request-deletion-screen";
|
|
14
15
|
export { RequestAccountDeletionScreen } from "./request-deletion-screen";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Public-Gate für die anonyme Account-Löschung. Matcht window.location.pathname:
|
|
3
|
+
// requestPath → RequestAccountDeletionScreen, confirmPath →
|
|
4
|
+
// ConfirmAccountDeletionScreen, sonst durch zur App. Spiegelt makeAuthGate
|
|
5
|
+
// (auth-email-password) — userDataRightsClient hängt es als Gate ein, die App
|
|
6
|
+
// listet den Client VOR dem Auth-Client, damit ein anonymer Besucher die Lösch-
|
|
7
|
+
// Maske statt der Login-Maske sieht. Path-Match beim Render: Apex-Übergänge
|
|
8
|
+
// (der Verify-Link) sind Full-Page-Loads, kein Client-Router.
|
|
9
|
+
//
|
|
10
|
+
// confirmPath MUSS dem Pfad der server-seitigen deletionVerifyUrl entsprechen —
|
|
11
|
+
// der ConfirmScreen liest das ?token aus eben dieser URL.
|
|
12
|
+
|
|
13
|
+
import type { ComponentType, ReactNode } from "react";
|
|
14
|
+
import { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen";
|
|
15
|
+
import { RequestAccountDeletionScreen } from "./request-deletion-screen";
|
|
16
|
+
|
|
17
|
+
export type PublicDeletionRoutes = {
|
|
18
|
+
/** Login-freie Route für die Email-Antrags-Maske (z.B. "/account/delete"). */
|
|
19
|
+
readonly requestPath: string;
|
|
20
|
+
/** Login-freie Route für die Token-Bestätigung; = Pfad der deletionVerifyUrl. */
|
|
21
|
+
readonly confirmPath: string;
|
|
22
|
+
/** Chrome um die Screen-Card. Default: vollflächig zentriert (wie der Auth-
|
|
23
|
+
* defaultShell). Apps reichen ihre eigene Shell (z.B. Marketing-Header). */
|
|
24
|
+
readonly shell?: (screen: ReactNode) => ReactNode;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const centeredShell = (screen: ReactNode): ReactNode => (
|
|
28
|
+
<div className="min-h-screen flex items-center justify-center bg-background px-4">{screen}</div>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export function makePublicDeletionGate(
|
|
32
|
+
routes: PublicDeletionRoutes,
|
|
33
|
+
): ComponentType<{ children: ReactNode }> {
|
|
34
|
+
const shell = routes.shell ?? centeredShell;
|
|
35
|
+
function PublicDeletionGate({ children }: { readonly children: ReactNode }): ReactNode {
|
|
36
|
+
const path = window.location.pathname;
|
|
37
|
+
if (path === routes.requestPath) return shell(<RequestAccountDeletionScreen />);
|
|
38
|
+
if (path === routes.confirmPath) return shell(<ConfirmAccountDeletionScreen />);
|
|
39
|
+
return <>{children}</>;
|
|
40
|
+
}
|
|
41
|
+
return PublicDeletionGate;
|
|
42
|
+
}
|
|
@@ -146,6 +146,44 @@ describe("ProfileScreen", () => {
|
|
|
146
146
|
fetchSpy.mockRestore();
|
|
147
147
|
}
|
|
148
148
|
});
|
|
149
|
+
|
|
150
|
+
// #472/1: der Server antwortet auf den Verification-Versand mit ok:false
|
|
151
|
+
// (z.B. 4xx) OHNE zu werfen. Das ist ein anderer Zweig als das catch oben —
|
|
152
|
+
// er muss eigenständig geloggt werden, der Wechsel bleibt erfolgreich.
|
|
153
|
+
test("email change: verification-send rejected by server (ok:false) is surfaced", async () => {
|
|
154
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
155
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
|
|
156
|
+
new Response("{}", { status: 400 }),
|
|
157
|
+
);
|
|
158
|
+
try {
|
|
159
|
+
const view = renderProfile(activeMe);
|
|
160
|
+
await waitFor(() => {
|
|
161
|
+
if (view.queryByTestId("profile-email") === null) throw new Error("not mounted yet");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const emailInput = view.container.querySelector<HTMLInputElement>("#profile-new-email");
|
|
165
|
+
const pwInput = view.container.querySelector<HTMLInputElement>("#profile-email-password");
|
|
166
|
+
if (!emailInput || !pwInput) throw new Error("email form inputs not found");
|
|
167
|
+
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
|
168
|
+
fireEvent.change(pwInput, { target: { value: "current-pw" } });
|
|
169
|
+
fireEvent.click(view.getByTestId("profile-email-submit"));
|
|
170
|
+
|
|
171
|
+
// Der ok:false-Zweig loggt SEINE Message ("could not be sent"),
|
|
172
|
+
// nicht die des catch-Zweigs ("send threw").
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
const hit = warnSpy.mock.calls.some((c) => String(c[0]).includes("could not be sent"));
|
|
175
|
+
if (!hit) throw new Error("ok:false verification failure not surfaced");
|
|
176
|
+
});
|
|
177
|
+
expect(warnSpy.mock.calls.some((c) => String(c[0]).includes("send threw"))).toBe(false);
|
|
178
|
+
// Wechsel bleibt erfolgreich: das Eingabefeld wird zurückgesetzt.
|
|
179
|
+
await waitFor(() => {
|
|
180
|
+
if (emailInput.value !== "") throw new Error("email input not cleared after success");
|
|
181
|
+
});
|
|
182
|
+
} finally {
|
|
183
|
+
warnSpy.mockRestore();
|
|
184
|
+
fetchSpy.mockRestore();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
149
187
|
});
|
|
150
188
|
|
|
151
189
|
describe("formatDeletionDate", () => {
|