@cosmicdrift/kumiko-bundled-features 0.68.0 → 0.71.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/config/__tests__/deserialize-value.test.ts +67 -0
- package/src/data-retention/__tests__/parse-override.test.ts +56 -0
- package/src/managed-pages/__tests__/branding-coerce.test.ts +79 -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 +4 -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/tags/web/tag-section.tsx +27 -21
- package/src/template-resolver/__tests__/conformance.integration.test.ts +19 -0
- package/src/template-resolver/testing.ts +6 -0
- package/src/user/schema/user.ts +9 -8
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +7 -5
- package/src/user-data-rights/lib/update-user-lifecycle.ts +21 -8
- package/src/user-data-rights/web/privacy-center-screen.tsx +4 -1
- package/src/user-profile/__tests__/profile-screen.test.tsx +46 -2
- package/src/user-profile/web/profile-screen.tsx +95 -93
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.71.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.71.0",
|
|
88
|
+
"@cosmicdrift/kumiko-framework": "0.71.0",
|
|
89
|
+
"@cosmicdrift/kumiko-headless": "0.71.0",
|
|
90
|
+
"@cosmicdrift/kumiko-renderer": "0.71.0",
|
|
91
|
+
"@cosmicdrift/kumiko-renderer-web": "0.71.0",
|
|
92
92
|
"@mollie/api-client": "^4.5.0",
|
|
93
93
|
"@node-rs/argon2": "^2.0.2",
|
|
94
94
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { deserializeValue } from "../resolver";
|
|
3
|
+
|
|
4
|
+
// deserializeValue is the read boundary for every config value: the DB stores
|
|
5
|
+
// the JSON-encoded raw string, this turns it back into a typed primitive per the
|
|
6
|
+
// key's declared `type`. The coercion has non-obvious paths worth pinning so a
|
|
7
|
+
// refactor can't quietly change them: a stored value whose JSON type disagrees
|
|
8
|
+
// with the declared type is NOT rejected — it is coerced (number via Number(),
|
|
9
|
+
// boolean only via literal true / the string "true", text via String()).
|
|
10
|
+
|
|
11
|
+
describe("deserializeValue", () => {
|
|
12
|
+
test("null raw short-circuits to undefined before any parse", () => {
|
|
13
|
+
expect(deserializeValue(null, "text")).toBeUndefined();
|
|
14
|
+
expect(deserializeValue(null, "number")).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("invalid JSON throws (never returns a half-coerced value)", () => {
|
|
18
|
+
expect(() => deserializeValue("{not json", "text")).toThrow();
|
|
19
|
+
expect(() => deserializeValue("", "number")).toThrow();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("number", () => {
|
|
23
|
+
test("a JSON number passes through verbatim", () => {
|
|
24
|
+
expect(deserializeValue("42", "number")).toBe(42);
|
|
25
|
+
expect(deserializeValue("3.14", "number")).toBe(3.14);
|
|
26
|
+
expect(deserializeValue("0", "number")).toBe(0);
|
|
27
|
+
expect(deserializeValue("-5", "number")).toBe(-5);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("a stringified number is coerced via Number() — not rejected", () => {
|
|
31
|
+
expect(deserializeValue('"42"', "number")).toBe(42);
|
|
32
|
+
expect(deserializeValue("true", "number")).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("an uncoercible value yields NaN rather than throwing", () => {
|
|
36
|
+
expect(deserializeValue('"abc"', "number")).toBeNaN();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("boolean", () => {
|
|
41
|
+
test("a JSON boolean passes through verbatim", () => {
|
|
42
|
+
expect(deserializeValue("true", "boolean")).toBe(true);
|
|
43
|
+
expect(deserializeValue("false", "boolean")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('only the string "true" coerces truthy — every other non-boolean is false', () => {
|
|
47
|
+
expect(deserializeValue('"true"', "boolean")).toBe(true);
|
|
48
|
+
expect(deserializeValue('"false"', "boolean")).toBe(false);
|
|
49
|
+
expect(deserializeValue('"1"', "boolean")).toBe(false);
|
|
50
|
+
expect(deserializeValue("1", "boolean")).toBe(false);
|
|
51
|
+
expect(deserializeValue("0", "boolean")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("text / select", () => {
|
|
56
|
+
test("a JSON string passes through verbatim", () => {
|
|
57
|
+
expect(deserializeValue('"hello"', "text")).toBe("hello");
|
|
58
|
+
expect(deserializeValue('"hello"', "select")).toBe("hello");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("non-string JSON is stringified via String()", () => {
|
|
62
|
+
expect(deserializeValue("42", "text")).toBe("42");
|
|
63
|
+
expect(deserializeValue("true", "select")).toBe("true");
|
|
64
|
+
expect(deserializeValue("null", "text")).toBe("null");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { parseRetentionOverrideOrNull } from "../_internal/parse-override";
|
|
3
|
+
|
|
4
|
+
// parseRetentionOverrideOrNull is the read boundary for a tenant's stored
|
|
5
|
+
// data-retention override (DSGVO-relevant policy in a config column). It must
|
|
6
|
+
// never let a corrupt or schema-violating value reach the retention decision:
|
|
7
|
+
// invalid JSON and schema drift both collapse to null (the resolver then falls
|
|
8
|
+
// back to preset/entity defaults) AND surface one operator warning. The schema
|
|
9
|
+
// itself is covered by override-schema.test.ts — this pins the parser's
|
|
10
|
+
// defensive wrapping: empty guard, no-throw on corruption, drop-not-leak on drift.
|
|
11
|
+
|
|
12
|
+
const parse = (raw: string | null) => parseRetentionOverrideOrNull(raw, "tenant-1", "test");
|
|
13
|
+
|
|
14
|
+
describe("parseRetentionOverrideOrNull", () => {
|
|
15
|
+
test("null / empty / whitespace-only raw → null before any parse", () => {
|
|
16
|
+
expect(parse(null)).toBeNull();
|
|
17
|
+
expect(parse("")).toBeNull();
|
|
18
|
+
expect(parse(" ")).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a valid override returns the parsed, schema-checked object", () => {
|
|
22
|
+
expect(parse('{"keepFor":"30d","strategy":"hardDelete","reference":"completedAt"}')).toEqual({
|
|
23
|
+
keepFor: "30d",
|
|
24
|
+
strategy: "hardDelete",
|
|
25
|
+
reference: "completedAt",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("empty object is a valid override (every field optional)", () => {
|
|
30
|
+
expect(parse("{}")).toEqual({});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("corrupt JSON returns null without throwing", () => {
|
|
34
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
35
|
+
expect(() => parse("{not json")).not.toThrow();
|
|
36
|
+
expect(parse("{not json")).toBeNull();
|
|
37
|
+
warn.mockRestore();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("JSON that parses but violates the schema is dropped to null — never leaked through", () => {
|
|
41
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
42
|
+
expect(parse('{"strategy":"delete"}')).toBeNull(); // enum drift
|
|
43
|
+
expect(parse('{"keepFor":"30days"}')).toBeNull(); // keepFor format drift
|
|
44
|
+
expect(parse('{"keepFor":42}')).toBeNull(); // wrong type
|
|
45
|
+
expect(parse('{"unknownKey":1}')).toBeNull(); // strict() rejects extra keys
|
|
46
|
+
warn.mockRestore();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("each dropped value surfaces exactly one operator warning", () => {
|
|
50
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
51
|
+
parse("{not json");
|
|
52
|
+
parse('{"strategy":"delete"}');
|
|
53
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
54
|
+
warn.mockRestore();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { type BrandingTokens, EMPTY_BRANDING } from "../../page-render";
|
|
3
|
+
import { coerceBranding } from "../branding";
|
|
4
|
+
|
|
5
|
+
// coerceBranding is the IO boundary for the branding query's wire response:
|
|
6
|
+
// untrusted `unknown` → BrandingTokens with no `as` cast. Every missing or
|
|
7
|
+
// non-string field must collapse to "" so a malformed/empty response renders
|
|
8
|
+
// the unbranded default rather than throwing — and an attacker-controlled
|
|
9
|
+
// non-string (e.g. a logoUrl object) can never leak through as a live render
|
|
10
|
+
// token. Exercised only indirectly by the integration path before this.
|
|
11
|
+
|
|
12
|
+
describe("coerceBranding", () => {
|
|
13
|
+
const FULL: BrandingTokens = {
|
|
14
|
+
title: "Acme",
|
|
15
|
+
description: "We make things",
|
|
16
|
+
siteUrl: "https://acme.test",
|
|
17
|
+
accentColor: "#abcdef",
|
|
18
|
+
logoUrl: "https://acme.test/logo.png",
|
|
19
|
+
layoutPreset: "wide",
|
|
20
|
+
customCss: ":root{--brand:1}",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
test("passes a fully-populated response through verbatim", () => {
|
|
24
|
+
expect(coerceBranding({ ...FULL })).toEqual(FULL);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("null / undefined / primitives collapse to EMPTY_BRANDING", () => {
|
|
28
|
+
for (const bad of [null, undefined, "string", 42, true, Symbol("x")]) {
|
|
29
|
+
expect(coerceBranding(bad)).toEqual(EMPTY_BRANDING);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("empty object yields the all-empty token set", () => {
|
|
34
|
+
expect(coerceBranding({})).toEqual(EMPTY_BRANDING);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("missing fields fall back to '' (partial response)", () => {
|
|
38
|
+
expect(coerceBranding({ title: "Acme", logoUrl: "https://acme.test/l.png" })).toEqual({
|
|
39
|
+
...EMPTY_BRANDING,
|
|
40
|
+
title: "Acme",
|
|
41
|
+
logoUrl: "https://acme.test/l.png",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("non-string field values are dropped to '' — never stringified or leaked", () => {
|
|
46
|
+
const hostile = {
|
|
47
|
+
title: 123,
|
|
48
|
+
description: null,
|
|
49
|
+
siteUrl: { toString: () => "https://evil.test" },
|
|
50
|
+
accentColor: ["#fff"],
|
|
51
|
+
logoUrl: true,
|
|
52
|
+
layoutPreset: undefined,
|
|
53
|
+
customCss: { malicious: "body{}" },
|
|
54
|
+
};
|
|
55
|
+
expect(coerceBranding(hostile)).toEqual(EMPTY_BRANDING);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("one hostile non-string field does not poison its valid siblings", () => {
|
|
59
|
+
const result = coerceBranding({ ...FULL, logoUrl: { href: "javascript:alert(1)" } });
|
|
60
|
+
expect(result.logoUrl).toBe("");
|
|
61
|
+
expect(result.title).toBe("Acme");
|
|
62
|
+
expect(result.siteUrl).toBe("https://acme.test");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("unknown extra keys are ignored — only the known tokens are extracted", () => {
|
|
66
|
+
const result = coerceBranding({ ...FULL, evil: "<script>", extra: 1 });
|
|
67
|
+
expect(result).toEqual(FULL);
|
|
68
|
+
expect(Object.keys(result).sort()).toEqual(Object.keys(EMPTY_BRANDING).sort());
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("inherited (non-own) properties are not picked up", () => {
|
|
72
|
+
const withInheritedTitle = Object.create({ title: "from-prototype" });
|
|
73
|
+
expect(coerceBranding(withInheritedTitle)).toEqual(EMPTY_BRANDING);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("array input is treated as a fieldless object → all-empty tokens", () => {
|
|
77
|
+
expect(coerceBranding(["title", "x"])).toEqual(EMPTY_BRANDING);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -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,10 @@ 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
|
+
// sessionChecker reads read_users on every authenticated request (status
|
|
47
|
+
// gate for locked accounts) — make that a boot-time dependency so a
|
|
48
|
+
// sessions-without-user wiring fails validateBoot instead of 500ing live.
|
|
49
|
+
r.requires("user");
|
|
46
50
|
// read_user_sessions is a hot-path direct-write store: sessionCreator
|
|
47
51
|
// inserts and the revoke handlers update rows WITHOUT emitting lifecycle
|
|
48
52
|
// 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>
|
|
@@ -168,7 +168,7 @@ export function TagSection({
|
|
|
168
168
|
};
|
|
169
169
|
|
|
170
170
|
return (
|
|
171
|
-
<div data-testid="tags-section">
|
|
171
|
+
<div data-testid="tags-section" className="flex flex-col gap-4">
|
|
172
172
|
<Field id="tags-section-select" label={t("tags.section.label")}>
|
|
173
173
|
<Input
|
|
174
174
|
kind="combobox"
|
|
@@ -184,26 +184,32 @@ export function TagSection({
|
|
|
184
184
|
/>
|
|
185
185
|
</Field>
|
|
186
186
|
|
|
187
|
-
{/*
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
187
|
+
{/* Inline create-row: das Label-Input wächst, der Add-Button sitzt
|
|
188
|
+
rechts daneben (items-end → bündig zur Input-Unterkante).
|
|
189
|
+
ponytail: separate row, weil die Combobox keine create-on-type-
|
|
190
|
+
Affordance hat. Fold-in, wenn der renderer-web-Combobox ein
|
|
191
|
+
freeSolo/onCreate-Prop bekommt. */}
|
|
192
|
+
<div className="flex items-end gap-2">
|
|
193
|
+
<div className="flex-1">
|
|
194
|
+
<Field id="tags-section-new" label={t("tags.section.newLabel")}>
|
|
195
|
+
<Input
|
|
196
|
+
kind="text"
|
|
197
|
+
id="tags-section-new"
|
|
198
|
+
name="newTag"
|
|
199
|
+
value={newName}
|
|
200
|
+
onChange={setNewName}
|
|
201
|
+
/>
|
|
202
|
+
</Field>
|
|
203
|
+
</div>
|
|
204
|
+
<Button
|
|
205
|
+
variant="secondary"
|
|
206
|
+
disabled={busy || newName.trim() === ""}
|
|
207
|
+
onClick={() => createAndAssign()}
|
|
208
|
+
testId="tags-section-create"
|
|
209
|
+
>
|
|
210
|
+
{busy ? t("tags.section.working") : t("tags.section.create")}
|
|
211
|
+
</Button>
|
|
212
|
+
</div>
|
|
207
213
|
|
|
208
214
|
{errorKey !== null && (
|
|
209
215
|
<Banner variant="error" testId="tags-section-action-error">
|
|
@@ -9,6 +9,7 @@ import { createTemplateResolverApi, TemplateNotFoundError } from "../api";
|
|
|
9
9
|
import { createTemplateResolverFeature } from "../feature";
|
|
10
10
|
import { templateResourceEntity } from "../table";
|
|
11
11
|
import {
|
|
12
|
+
assertConsumerHandlesMissingResourceKeys,
|
|
12
13
|
assertConsumerHandlesNotFound,
|
|
13
14
|
runTemplateConsumerConformance,
|
|
14
15
|
type TemplateConsumer,
|
|
@@ -62,6 +63,24 @@ describe("template-resolver :: conformance harness", () => {
|
|
|
62
63
|
).rejects.toThrow("expected TemplateNotFoundError, received Error");
|
|
63
64
|
});
|
|
64
65
|
|
|
66
|
+
// 446#1: a consumer whose resolveResources throws a non-TypeError used to
|
|
67
|
+
// fall through the catch and pass — the assertion was effectively a no-op.
|
|
68
|
+
test("harness detects a consumer that throws (non-TypeError) on missing resource keys", async () => {
|
|
69
|
+
const badConsumer: TemplateConsumer = {
|
|
70
|
+
resolve: (args) => createTemplateResolverApi(db).resolveTemplate(args),
|
|
71
|
+
resolveResources: async () => {
|
|
72
|
+
throw new Error("blew up on a missing key instead of degrading");
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
await expect(
|
|
77
|
+
assertConsumerHandlesMissingResourceKeys(badConsumer, {
|
|
78
|
+
getDb: () => db,
|
|
79
|
+
tenantId: TENANT_A,
|
|
80
|
+
}),
|
|
81
|
+
).rejects.toThrow("threw unexpectedly");
|
|
82
|
+
});
|
|
83
|
+
|
|
65
84
|
test("conformant consumer propagates TemplateNotFoundError", async () => {
|
|
66
85
|
const apiConsumer: TemplateConsumer = {
|
|
67
86
|
resolve: (args) => createTemplateResolverApi(db).resolveTemplate(args),
|
|
@@ -169,6 +169,12 @@ export async function assertConsumerHandlesMissingResourceKeys(
|
|
|
169
169
|
`resolveResources threw TypeError (unhandled missing key?): ${err.message}`,
|
|
170
170
|
);
|
|
171
171
|
}
|
|
172
|
+
// Any other throw means the consumer did not handle missing keys
|
|
173
|
+
// gracefully. Falling through here silently passed the assertion — a
|
|
174
|
+
// broken consumer looked conformant.
|
|
175
|
+
throw new ConformanceAssertionError(
|
|
176
|
+
`resolveResources threw unexpectedly (should handle missing keys gracefully): ${err.message}`,
|
|
177
|
+
);
|
|
172
178
|
}
|
|
173
179
|
}
|
|
174
180
|
|
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
|
|
@@ -220,8 +221,9 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
|
|
|
220
221
|
// Bestand wieder in den divergenten Live-State bringen (der Rebuild hat ihn
|
|
221
222
|
// auf Active gesetzt) und den Reconcile laufen lassen.
|
|
222
223
|
await updateMany(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: created.id });
|
|
223
|
-
const backfilled = await backfillUserLifecycleEvents(stack.db);
|
|
224
|
+
const { backfilled, failed } = await backfillUserLifecycleEvents(stack.db);
|
|
224
225
|
expect(backfilled).toBeGreaterThanOrEqual(1);
|
|
226
|
+
expect(failed).toEqual([]);
|
|
225
227
|
|
|
226
228
|
// Jetzt traegt das Event-Log den State -> Rebuild bewahrt ihn.
|
|
227
229
|
await rebuildProjection(USER_PROJECTION, { db: stack.db, registry });
|
|
@@ -73,7 +73,12 @@ export async function updateUserLifecycle(
|
|
|
73
73
|
// user.updated an — harmlos, last-write-wins beim Replay).
|
|
74
74
|
// ponytail: full read_users-Scan, in JS gefiltert — einmalige Migration, kein
|
|
75
75
|
// Index/Streaming noetig. Bei Millionen-Rows: batchen.
|
|
76
|
-
export
|
|
76
|
+
export type BackfillResult = {
|
|
77
|
+
readonly backfilled: number;
|
|
78
|
+
readonly failed: ReadonlyArray<{ readonly id: string; readonly error: string }>;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export async function backfillUserLifecycleEvents(conn: DbRunner): Promise<BackfillResult> {
|
|
77
82
|
const rows = (await selectMany(conn, userTable, {})) as Array<{
|
|
78
83
|
id: string;
|
|
79
84
|
status: string;
|
|
@@ -82,6 +87,7 @@ export async function backfillUserLifecycleEvents(conn: DbRunner): Promise<numbe
|
|
|
82
87
|
}>;
|
|
83
88
|
|
|
84
89
|
let backfilled = 0;
|
|
90
|
+
const failed: Array<{ id: string; error: string }> = [];
|
|
85
91
|
for (const row of rows) {
|
|
86
92
|
const divergent =
|
|
87
93
|
row.status !== USER_STATUS.Active ||
|
|
@@ -89,12 +95,19 @@ export async function backfillUserLifecycleEvents(conn: DbRunner): Promise<numbe
|
|
|
89
95
|
row.pendingDeletionRequestId != null;
|
|
90
96
|
if (!divergent) continue;
|
|
91
97
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
// One bad row must not abort the run: the rows after it would then never
|
|
99
|
+
// get their user.updated event and stay vulnerable to the rebuild wipe
|
|
100
|
+
// (DSGVO-Datenverlust). Collect failures, finish the estate, report them.
|
|
101
|
+
try {
|
|
102
|
+
await updateUserLifecycle(conn, row.id, {
|
|
103
|
+
status: row.status,
|
|
104
|
+
gracePeriodEnd: row.gracePeriodEnd,
|
|
105
|
+
pendingDeletionRequestId: row.pendingDeletionRequestId,
|
|
106
|
+
});
|
|
107
|
+
backfilled++;
|
|
108
|
+
} catch (e) {
|
|
109
|
+
failed.push({ id: row.id, error: e instanceof Error ? e.message : String(e) });
|
|
110
|
+
}
|
|
98
111
|
}
|
|
99
|
-
return backfilled;
|
|
112
|
+
return { backfilled, failed };
|
|
100
113
|
}
|
|
@@ -121,6 +121,9 @@ function ExportSection(): ReactNode {
|
|
|
121
121
|
<p className="text-sm text-muted-foreground">
|
|
122
122
|
{t("userDataRights.privacyCenter.export.intro")}
|
|
123
123
|
</p>
|
|
124
|
+
{statusQuery.error && (
|
|
125
|
+
<Banner variant="error">{t("userDataRights.privacyCenter.errors.generic")}</Banner>
|
|
126
|
+
)}
|
|
124
127
|
{inProgress && (
|
|
125
128
|
<Banner variant="info" testId="privacy-export-pending">
|
|
126
129
|
{t("userDataRights.privacyCenter.export.pending")}
|
|
@@ -189,7 +192,7 @@ function AuditSection(): ReactNode {
|
|
|
189
192
|
{logQuery.error && (
|
|
190
193
|
<Banner variant="error">{t("userDataRights.privacyCenter.errors.generic")}</Banner>
|
|
191
194
|
)}
|
|
192
|
-
{rows.length === 0 ? (
|
|
195
|
+
{logQuery.error ? null : rows.length === 0 ? (
|
|
193
196
|
<p className="text-sm text-muted-foreground" data-testid="privacy-audit-empty">
|
|
194
197
|
{t("userDataRights.privacyCenter.audit.empty")}
|
|
195
198
|
</p>
|
|
@@ -80,6 +80,12 @@ describe("ProfileScreen", () => {
|
|
|
80
80
|
expect(view.getByTestId("profile-danger-delete")).toBeTruthy();
|
|
81
81
|
// Echte i18n: kein einziger roher Key im sichtbaren Text.
|
|
82
82
|
expect(view.container.textContent).not.toContain("profile.");
|
|
83
|
+
// Card-Standard: jede Konto-Section ist GENAU eine Card (self + descendants)
|
|
84
|
+
// — nicht mehr das alte <section bg-card> um eine Form-Card = doppelt.
|
|
85
|
+
const cardCount = (el: Element): number =>
|
|
86
|
+
(el.matches(".bg-card") ? 1 : 0) + el.querySelectorAll(".bg-card").length;
|
|
87
|
+
expect(cardCount(view.getByTestId("profile-email"))).toBe(1);
|
|
88
|
+
expect(cardCount(view.getByTestId("profile-password"))).toBe(1);
|
|
83
89
|
});
|
|
84
90
|
|
|
85
91
|
test("deletionRequested: Frist-Banner + Abbrechen statt Lösch-Button", async () => {
|
|
@@ -113,7 +119,7 @@ describe("ProfileScreen", () => {
|
|
|
113
119
|
try {
|
|
114
120
|
const view = renderProfile(activeMe);
|
|
115
121
|
await waitFor(() => {
|
|
116
|
-
if (view.queryByTestId("profile-email
|
|
122
|
+
if (view.queryByTestId("profile-email") === null) throw new Error("not mounted yet");
|
|
117
123
|
});
|
|
118
124
|
|
|
119
125
|
const emailInput = view.container.querySelector<HTMLInputElement>("#profile-new-email");
|
|
@@ -121,7 +127,7 @@ describe("ProfileScreen", () => {
|
|
|
121
127
|
if (!emailInput || !pwInput) throw new Error("email form inputs not found");
|
|
122
128
|
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
|
123
129
|
fireEvent.change(pwInput, { target: { value: "current-pw" } });
|
|
124
|
-
fireEvent.
|
|
130
|
+
fireEvent.click(view.getByTestId("profile-email-submit"));
|
|
125
131
|
|
|
126
132
|
// De-Swallow: der fehlgeschlagene Verification-Versand wird geloggt.
|
|
127
133
|
await waitFor(() => {
|
|
@@ -140,6 +146,44 @@ describe("ProfileScreen", () => {
|
|
|
140
146
|
fetchSpy.mockRestore();
|
|
141
147
|
}
|
|
142
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
|
+
});
|
|
143
187
|
});
|
|
144
188
|
|
|
145
189
|
describe("formatDeletionDate", () => {
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
useQuery,
|
|
13
13
|
useTranslation,
|
|
14
14
|
} from "@cosmicdrift/kumiko-renderer";
|
|
15
|
-
import { type
|
|
15
|
+
import { type ReactNode, useState } from "react";
|
|
16
16
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
17
17
|
import { requestEmailVerification } from "../../auth-email-password/web";
|
|
18
18
|
import { UserDataRightsHandlers, UserProfileHandlers, UserProfileQueries } from "../constants";
|
|
@@ -61,15 +61,14 @@ function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode
|
|
|
61
61
|
|
|
62
62
|
function ChangePasswordSection(): ReactNode {
|
|
63
63
|
const t = useTranslation();
|
|
64
|
-
const {
|
|
64
|
+
const { Section, Field, Input, Button } = usePrimitives();
|
|
65
65
|
const dispatcher = useDispatcher();
|
|
66
66
|
const [oldPassword, setOldPassword] = useState("");
|
|
67
67
|
const [newPassword, setNewPassword] = useState("");
|
|
68
68
|
const [confirm, setConfirm] = useState("");
|
|
69
69
|
const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
|
|
70
70
|
|
|
71
|
-
const onSubmit = (
|
|
72
|
-
e?.preventDefault();
|
|
71
|
+
const onSubmit = (): void => {
|
|
73
72
|
void (async () => {
|
|
74
73
|
if (newPassword !== confirm) {
|
|
75
74
|
setStatus({ kind: "error", messageKey: "profile.password.mismatch" });
|
|
@@ -93,54 +92,53 @@ function ChangePasswordSection(): ReactNode {
|
|
|
93
92
|
|
|
94
93
|
const submitting = status.kind === "submitting";
|
|
95
94
|
return (
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<Form onSubmit={onSubmit} testId="profile-password-form">
|
|
102
|
-
<Field id="profile-old-password" label={t("profile.password.old")} required>
|
|
103
|
-
<Input
|
|
104
|
-
kind="password"
|
|
105
|
-
id="profile-old-password"
|
|
106
|
-
name="profile-old-password"
|
|
107
|
-
value={oldPassword}
|
|
108
|
-
onChange={setOldPassword}
|
|
109
|
-
disabled={submitting}
|
|
110
|
-
required
|
|
111
|
-
autoComplete="current-password"
|
|
112
|
-
/>
|
|
113
|
-
</Field>
|
|
114
|
-
<Field id="profile-new-password" label={t("profile.password.new")} required>
|
|
115
|
-
<Input
|
|
116
|
-
kind="password"
|
|
117
|
-
id="profile-new-password"
|
|
118
|
-
name="profile-new-password"
|
|
119
|
-
value={newPassword}
|
|
120
|
-
onChange={setNewPassword}
|
|
121
|
-
disabled={submitting}
|
|
122
|
-
required
|
|
123
|
-
autoComplete="new-password"
|
|
124
|
-
/>
|
|
125
|
-
</Field>
|
|
126
|
-
<Field id="profile-confirm-password" label={t("profile.password.confirm")} required>
|
|
127
|
-
<Input
|
|
128
|
-
kind="password"
|
|
129
|
-
id="profile-confirm-password"
|
|
130
|
-
name="profile-confirm-password"
|
|
131
|
-
value={confirm}
|
|
132
|
-
onChange={setConfirm}
|
|
133
|
-
disabled={submitting}
|
|
134
|
-
required
|
|
135
|
-
autoComplete="new-password"
|
|
136
|
-
/>
|
|
137
|
-
</Field>
|
|
138
|
-
<StatusBanner status={status} />
|
|
139
|
-
<Button type="submit" disabled={submitting} testId="profile-password-submit">
|
|
95
|
+
<Section
|
|
96
|
+
testId="profile-password"
|
|
97
|
+
title={t("profile.password.title")}
|
|
98
|
+
actions={
|
|
99
|
+
<Button onClick={() => onSubmit()} disabled={submitting} testId="profile-password-submit">
|
|
140
100
|
{t("profile.password.submit")}
|
|
141
101
|
</Button>
|
|
142
|
-
|
|
143
|
-
|
|
102
|
+
}
|
|
103
|
+
>
|
|
104
|
+
<Field id="profile-old-password" label={t("profile.password.old")} required>
|
|
105
|
+
<Input
|
|
106
|
+
kind="password"
|
|
107
|
+
id="profile-old-password"
|
|
108
|
+
name="profile-old-password"
|
|
109
|
+
value={oldPassword}
|
|
110
|
+
onChange={setOldPassword}
|
|
111
|
+
disabled={submitting}
|
|
112
|
+
required
|
|
113
|
+
autoComplete="current-password"
|
|
114
|
+
/>
|
|
115
|
+
</Field>
|
|
116
|
+
<Field id="profile-new-password" label={t("profile.password.new")} required>
|
|
117
|
+
<Input
|
|
118
|
+
kind="password"
|
|
119
|
+
id="profile-new-password"
|
|
120
|
+
name="profile-new-password"
|
|
121
|
+
value={newPassword}
|
|
122
|
+
onChange={setNewPassword}
|
|
123
|
+
disabled={submitting}
|
|
124
|
+
required
|
|
125
|
+
autoComplete="new-password"
|
|
126
|
+
/>
|
|
127
|
+
</Field>
|
|
128
|
+
<Field id="profile-confirm-password" label={t("profile.password.confirm")} required>
|
|
129
|
+
<Input
|
|
130
|
+
kind="password"
|
|
131
|
+
id="profile-confirm-password"
|
|
132
|
+
name="profile-confirm-password"
|
|
133
|
+
value={confirm}
|
|
134
|
+
onChange={setConfirm}
|
|
135
|
+
disabled={submitting}
|
|
136
|
+
required
|
|
137
|
+
autoComplete="new-password"
|
|
138
|
+
/>
|
|
139
|
+
</Field>
|
|
140
|
+
<StatusBanner status={status} />
|
|
141
|
+
</Section>
|
|
144
142
|
);
|
|
145
143
|
}
|
|
146
144
|
|
|
@@ -152,14 +150,13 @@ function ChangeEmailSection({
|
|
|
152
150
|
readonly onChanged: () => void;
|
|
153
151
|
}): ReactNode {
|
|
154
152
|
const t = useTranslation();
|
|
155
|
-
const {
|
|
153
|
+
const { Section, Field, Input, Button } = usePrimitives();
|
|
156
154
|
const dispatcher = useDispatcher();
|
|
157
155
|
const [newEmail, setNewEmail] = useState("");
|
|
158
156
|
const [currentPassword, setCurrentPassword] = useState("");
|
|
159
157
|
const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
|
|
160
158
|
|
|
161
|
-
const onSubmit = (
|
|
162
|
-
e?.preventDefault();
|
|
159
|
+
const onSubmit = (): void => {
|
|
163
160
|
void (async () => {
|
|
164
161
|
setStatus({ kind: "submitting" });
|
|
165
162
|
const res = await dispatcher.write(UserProfileHandlers.changeEmail, {
|
|
@@ -197,45 +194,46 @@ function ChangeEmailSection({
|
|
|
197
194
|
|
|
198
195
|
const submitting = status.kind === "submitting";
|
|
199
196
|
return (
|
|
200
|
-
<
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
<
|
|
210
|
-
<Input
|
|
211
|
-
kind="email"
|
|
212
|
-
id="profile-new-email"
|
|
213
|
-
name="profile-new-email"
|
|
214
|
-
value={newEmail}
|
|
215
|
-
onChange={setNewEmail}
|
|
216
|
-
disabled={submitting}
|
|
217
|
-
required
|
|
218
|
-
autoComplete="email"
|
|
219
|
-
/>
|
|
220
|
-
</Field>
|
|
221
|
-
<Field id="profile-email-password" label={t("profile.email.currentPassword")} required>
|
|
222
|
-
<Input
|
|
223
|
-
kind="password"
|
|
224
|
-
id="profile-email-password"
|
|
225
|
-
name="profile-email-password"
|
|
226
|
-
value={currentPassword}
|
|
227
|
-
onChange={setCurrentPassword}
|
|
228
|
-
disabled={submitting}
|
|
229
|
-
required
|
|
230
|
-
autoComplete="current-password"
|
|
231
|
-
/>
|
|
232
|
-
</Field>
|
|
233
|
-
<StatusBanner status={status} />
|
|
234
|
-
<Button type="submit" disabled={submitting} testId="profile-email-submit">
|
|
197
|
+
<Section
|
|
198
|
+
testId="profile-email"
|
|
199
|
+
title={t("profile.email.title")}
|
|
200
|
+
subtitle={
|
|
201
|
+
<span data-testid="profile-email-current">
|
|
202
|
+
{t("profile.email.current")}: {me.email}
|
|
203
|
+
</span>
|
|
204
|
+
}
|
|
205
|
+
actions={
|
|
206
|
+
<Button onClick={() => onSubmit()} disabled={submitting} testId="profile-email-submit">
|
|
235
207
|
{t("profile.email.submit")}
|
|
236
208
|
</Button>
|
|
237
|
-
|
|
238
|
-
|
|
209
|
+
}
|
|
210
|
+
>
|
|
211
|
+
<Field id="profile-new-email" label={t("profile.email.new")} required>
|
|
212
|
+
<Input
|
|
213
|
+
kind="email"
|
|
214
|
+
id="profile-new-email"
|
|
215
|
+
name="profile-new-email"
|
|
216
|
+
value={newEmail}
|
|
217
|
+
onChange={setNewEmail}
|
|
218
|
+
disabled={submitting}
|
|
219
|
+
required
|
|
220
|
+
autoComplete="email"
|
|
221
|
+
/>
|
|
222
|
+
</Field>
|
|
223
|
+
<Field id="profile-email-password" label={t("profile.email.currentPassword")} required>
|
|
224
|
+
<Input
|
|
225
|
+
kind="password"
|
|
226
|
+
id="profile-email-password"
|
|
227
|
+
name="profile-email-password"
|
|
228
|
+
value={currentPassword}
|
|
229
|
+
onChange={setCurrentPassword}
|
|
230
|
+
disabled={submitting}
|
|
231
|
+
required
|
|
232
|
+
autoComplete="current-password"
|
|
233
|
+
/>
|
|
234
|
+
</Field>
|
|
235
|
+
<StatusBanner status={status} />
|
|
236
|
+
</Section>
|
|
239
237
|
);
|
|
240
238
|
}
|
|
241
239
|
|
|
@@ -349,10 +347,14 @@ export function ProfileScreen(): ReactNode {
|
|
|
349
347
|
};
|
|
350
348
|
|
|
351
349
|
return (
|
|
352
|
-
<div className="
|
|
350
|
+
<div className="flex max-w-5xl flex-col gap-6 p-6" data-testid="profile-screen">
|
|
353
351
|
<Heading variant="page">{t("profile.title")}</Heading>
|
|
354
|
-
|
|
355
|
-
|
|
352
|
+
{/* Die zwei kurzen Konto-Forms teilen sich eine Reihe (md+); die
|
|
353
|
+
Danger-Zone bleibt volle Breite darunter. */}
|
|
354
|
+
<div className="grid items-start gap-6 md:grid-cols-2">
|
|
355
|
+
<ChangeEmailSection me={me} onChanged={refetch} />
|
|
356
|
+
<ChangePasswordSection />
|
|
357
|
+
</div>
|
|
356
358
|
<DangerZoneSection me={me} onChanged={refetch} />
|
|
357
359
|
</div>
|
|
358
360
|
);
|