@cosmicdrift/kumiko-bundled-features 0.290.0 → 0.292.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 +9 -9
- package/src/audit/__tests__/audit-screens.boot.test.ts +40 -1
- package/src/audit/__tests__/audit.integration.test.ts +96 -2
- package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +8 -1
- package/src/audit/changes.json +7 -0
- package/src/audit/feature.ts +18 -4
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +31 -2
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +120 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +72 -0
- package/src/auth-email-password/web/auth-form-logic.ts +5 -5
- package/src/auth-email-password/web/invite-accept-screen.tsx +46 -21
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +2 -0
- package/src/delivery/feature.ts +6 -1
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +132 -9
- package/src/file-foundation/changes.json +9 -1
- package/src/file-foundation/feature.ts +2 -0
- package/src/jobs/__tests__/tenant-job-failures.integration.test.ts +275 -0
- package/src/jobs/changes.json +7 -0
- package/src/jobs/constants.ts +1 -0
- package/src/jobs/db/queries/retention.ts +17 -1
- package/src/jobs/feature.ts +7 -1
- package/src/jobs/handlers/tenant-failures.query.ts +82 -0
- package/src/jobs/index.ts +1 -0
- package/src/jobs/job-run-logger.ts +73 -4
- package/src/jobs/tenant-job-failure-table.ts +39 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.test.ts +69 -1
- package/src/rate-limiting/changes.json +9 -1
- package/src/rate-limiting/constants.ts +1 -0
- package/src/rate-limiting/handlers/bucket-access.ts +39 -0
- package/src/rate-limiting/handlers/status.query.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.292.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>",
|
|
@@ -133,12 +133,12 @@
|
|
|
133
133
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
134
134
|
},
|
|
135
135
|
"dependencies": {
|
|
136
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
136
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.292.0",
|
|
137
|
+
"@cosmicdrift/kumiko-framework": "0.292.0",
|
|
138
|
+
"@cosmicdrift/kumiko-headless": "0.292.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer": "0.292.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer-web": "0.292.0",
|
|
141
|
+
"@cosmicdrift/kumiko-types": "0.292.0",
|
|
142
142
|
"@mollie/api-client": "^4.5.0",
|
|
143
143
|
"@node-rs/argon2": "^2.0.2",
|
|
144
144
|
"@types/mailparser": "^3.4.6",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
],
|
|
168
168
|
"devDependencies": {
|
|
169
169
|
"@testing-library/user-event": "^14.6.1",
|
|
170
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.292.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.292.0",
|
|
172
172
|
"jsqr": "^1.4.0"
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -1,19 +1,37 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
|
+
import {
|
|
5
|
+
isFieldsEditSection,
|
|
6
|
+
normalizeEditField,
|
|
7
|
+
normalizeListColumn,
|
|
8
|
+
sectionFieldSpecs,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
4
10
|
import { createConfigFeature } from "../../config/feature";
|
|
5
11
|
import { createTenantFeature } from "../../tenant/feature";
|
|
12
|
+
import { createUserFeature } from "../../user/feature";
|
|
6
13
|
import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
|
|
7
14
|
import { createAuditFeature } from "../feature";
|
|
8
15
|
import { AUDIT_I18N } from "../i18n";
|
|
9
16
|
|
|
10
17
|
describe("audit log screen + handler access alignment", () => {
|
|
11
|
-
const features = [
|
|
18
|
+
const features = [
|
|
19
|
+
createConfigFeature(),
|
|
20
|
+
createTenantFeature(),
|
|
21
|
+
createUserFeature(),
|
|
22
|
+
createAuditFeature(),
|
|
23
|
+
];
|
|
12
24
|
|
|
13
25
|
test("boot-validates with audit-log screen registered", () => {
|
|
14
26
|
expect(() => validateBoot(features)).not.toThrow();
|
|
15
27
|
});
|
|
16
28
|
|
|
29
|
+
test("the user:user actor reference makes `user` a hard boot dependency (fw#3103)", () => {
|
|
30
|
+
expect(() =>
|
|
31
|
+
validateBoot([createConfigFeature(), createTenantFeature(), createAuditFeature()]),
|
|
32
|
+
).toThrow(/requires feature "user"/);
|
|
33
|
+
});
|
|
34
|
+
|
|
17
35
|
test("audit-log screen is declarative, access.admin-gated", () => {
|
|
18
36
|
const audit = createAuditFeature();
|
|
19
37
|
const screen = audit.screens[AUDIT_LOG_SCREEN_ID];
|
|
@@ -63,6 +81,27 @@ describe("audit log screen + handler access alignment", () => {
|
|
|
63
81
|
expect(AUDIT_I18N[aggregateTypeKey]?.en).not.toBe(AUDIT_I18N[aggregateIdKey]?.en);
|
|
64
82
|
});
|
|
65
83
|
|
|
84
|
+
test("actor column and detail field declare the user:user reference (fw#3103)", () => {
|
|
85
|
+
const audit = createAuditFeature();
|
|
86
|
+
expect(audit.requires).toContain("user");
|
|
87
|
+
|
|
88
|
+
const list = audit.screens[AUDIT_LOG_SCREEN_ID];
|
|
89
|
+
if (list?.type !== "projectionList") throw new Error("expected a projectionList screen");
|
|
90
|
+
const actorColumn = list.columns.map(normalizeListColumn).find((c) => c.field === "createdBy");
|
|
91
|
+
expect(actorColumn?.refEntity).toBe("user:user");
|
|
92
|
+
expect(actorColumn?.refLabelField).toBe("displayName");
|
|
93
|
+
|
|
94
|
+
const detail = audit.screens[AUDIT_LOG_DETAIL_SCREEN_ID];
|
|
95
|
+
if (detail?.type !== "projectionDetail") throw new Error("expected a projectionDetail screen");
|
|
96
|
+
const actorField = detail.layout.sections
|
|
97
|
+
.filter(isFieldsEditSection)
|
|
98
|
+
.flatMap((section) => sectionFieldSpecs(section))
|
|
99
|
+
.map(normalizeEditField)
|
|
100
|
+
.find((f) => f.field === "createdBy");
|
|
101
|
+
expect(actorField?.refEntity).toBe("user:user");
|
|
102
|
+
expect(actorField?.refLabelField).toBe("displayName");
|
|
103
|
+
});
|
|
104
|
+
|
|
66
105
|
test("audit queries use access.admin (screen ⊆ handler)", () => {
|
|
67
106
|
const audit = createAuditFeature();
|
|
68
107
|
expect(rolesOf(audit.queryHandlers["list"]?.access)).toEqual([...access.admin]);
|
|
@@ -21,9 +21,11 @@ import {
|
|
|
21
21
|
unsafeCreateEntityTable,
|
|
22
22
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
23
|
import { withoutAmbientTemporal } from "@cosmicdrift/kumiko-framework/testing";
|
|
24
|
+
import { buildDateRangePayload, resolveDateRangeFacets } from "@cosmicdrift/kumiko-renderer";
|
|
24
25
|
import { createConfigFeature } from "../../config";
|
|
25
26
|
import { createTenantFeature } from "../../tenant";
|
|
26
|
-
import {
|
|
27
|
+
import { createUserFeature } from "../../user";
|
|
28
|
+
import { AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
|
|
27
29
|
import { createAuditFeature } from "../feature";
|
|
28
30
|
|
|
29
31
|
const widgetEntity = createEntity({
|
|
@@ -61,7 +63,13 @@ const otherTenantAdmin: SessionUser = createTestUser({
|
|
|
61
63
|
|
|
62
64
|
beforeAll(async () => {
|
|
63
65
|
stack = await setupTestStack({
|
|
64
|
-
features: [
|
|
66
|
+
features: [
|
|
67
|
+
widgetFeature,
|
|
68
|
+
createConfigFeature(),
|
|
69
|
+
createTenantFeature(),
|
|
70
|
+
createUserFeature(),
|
|
71
|
+
createAuditFeature(),
|
|
72
|
+
],
|
|
65
73
|
});
|
|
66
74
|
await unsafeCreateEntityTable(stack.db, widgetEntity);
|
|
67
75
|
});
|
|
@@ -251,6 +259,92 @@ describe("audit: list query", () => {
|
|
|
251
259
|
expect((untilRow.payload as { name?: string }).name).toBe("before-window");
|
|
252
260
|
});
|
|
253
261
|
|
|
262
|
+
// fw#3104: the audit screen's dateRange facet is the only UI that produces
|
|
263
|
+
// these bounds. The risk is not the handler (covered above) but the
|
|
264
|
+
// conversion of a calendar date into an instant: pick "the 14th" as the
|
|
265
|
+
// upper bound and an event at 23:59 local that day has to stay in the
|
|
266
|
+
// result. Bounds are derived from the live screen declaration, so a change
|
|
267
|
+
// to either end of the chain fails here.
|
|
268
|
+
test("dateRange facet bounds include an event at 23:59 of the `to` day", async () => {
|
|
269
|
+
const screen = createAuditFeature().screens[AUDIT_LOG_SCREEN_ID];
|
|
270
|
+
if (screen?.type !== "projectionList") throw new Error("expected a projectionList screen");
|
|
271
|
+
const specs = resolveDateRangeFacets(screen.facets, (key) => key);
|
|
272
|
+
expect(specs).toHaveLength(1);
|
|
273
|
+
|
|
274
|
+
await createWidget(admin, "late-on-the-14th");
|
|
275
|
+
await createWidget(admin, "just-after-midnight");
|
|
276
|
+
|
|
277
|
+
const listed = await stack.http.queryOk<AuditResponse>(AuditQueries.list, {}, admin);
|
|
278
|
+
const byName = new Map(
|
|
279
|
+
listed.rows
|
|
280
|
+
.filter((r) => r.type === "widget.created")
|
|
281
|
+
.map((r) => [(r.payload as { name?: string }).name ?? "", r.id]),
|
|
282
|
+
);
|
|
283
|
+
// 23:59 and 00:00 Europe/Vienna, expressed as the UTC instants the
|
|
284
|
+
// event store stores.
|
|
285
|
+
const stamps: ReadonlyArray<readonly [string, string]> = [
|
|
286
|
+
["late-on-the-14th", "2020-06-14T21:59:00Z"],
|
|
287
|
+
["just-after-midnight", "2020-06-14T22:00:00Z"],
|
|
288
|
+
];
|
|
289
|
+
for (const [name, createdAt] of stamps) {
|
|
290
|
+
const id = byName.get(name);
|
|
291
|
+
if (!id) throw new Error(`missing event for ${name}`);
|
|
292
|
+
await asRawClient(stack.db).unsafe(
|
|
293
|
+
`UPDATE kumiko_events SET created_at = $1::timestamptz WHERE id = $2::bigint`,
|
|
294
|
+
[createdAt, id],
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const bounds = buildDateRangePayload(
|
|
299
|
+
specs,
|
|
300
|
+
{ "createdAt.from": ["2020-06-14"], "createdAt.to": ["2020-06-14"] },
|
|
301
|
+
"Europe/Vienna",
|
|
302
|
+
);
|
|
303
|
+
const inRange = await stack.http.queryOk<AuditResponse>(AuditQueries.list, bounds, admin);
|
|
304
|
+
const names = inRange.rows.map((r) => (r.payload as { name?: string }).name);
|
|
305
|
+
expect(names).toContain("late-on-the-14th");
|
|
306
|
+
expect(names).not.toContain("just-after-midnight");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Open interval from the same declaration — only one bound set must still
|
|
310
|
+
// reach the handler as a single param.
|
|
311
|
+
test("dateRange facet with only the `from` bound sends an open interval", async () => {
|
|
312
|
+
const screen = createAuditFeature().screens[AUDIT_LOG_SCREEN_ID];
|
|
313
|
+
if (screen?.type !== "projectionList") throw new Error("expected a projectionList screen");
|
|
314
|
+
const specs = resolveDateRangeFacets(screen.facets, (key) => key);
|
|
315
|
+
|
|
316
|
+
await createWidget(admin, "old");
|
|
317
|
+
await createWidget(admin, "recent");
|
|
318
|
+
const listed = await stack.http.queryOk<AuditResponse>(AuditQueries.list, {}, admin);
|
|
319
|
+
const byName = new Map(
|
|
320
|
+
listed.rows
|
|
321
|
+
.filter((r) => r.type === "widget.created")
|
|
322
|
+
.map((r) => [(r.payload as { name?: string }).name ?? "", r.id]),
|
|
323
|
+
);
|
|
324
|
+
for (const [name, createdAt] of [
|
|
325
|
+
["old", "2020-06-01T12:00:00Z"],
|
|
326
|
+
["recent", "2020-06-20T12:00:00Z"],
|
|
327
|
+
] as const) {
|
|
328
|
+
const id = byName.get(name);
|
|
329
|
+
if (!id) throw new Error(`missing event for ${name}`);
|
|
330
|
+
await asRawClient(stack.db).unsafe(
|
|
331
|
+
`UPDATE kumiko_events SET created_at = $1::timestamptz WHERE id = $2::bigint`,
|
|
332
|
+
[createdAt, id],
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const bounds = buildDateRangePayload(
|
|
337
|
+
specs,
|
|
338
|
+
{ "createdAt.from": ["2020-06-10"] },
|
|
339
|
+
"Europe/Vienna",
|
|
340
|
+
);
|
|
341
|
+
expect(Object.keys(bounds)).toEqual(["from"]);
|
|
342
|
+
const sinceFrom = await stack.http.queryOk<AuditResponse>(AuditQueries.list, bounds, admin);
|
|
343
|
+
const names = sinceFrom.rows.map((r) => (r.payload as { name?: string }).name);
|
|
344
|
+
expect(names).toContain("recent");
|
|
345
|
+
expect(names).not.toContain("old");
|
|
346
|
+
});
|
|
347
|
+
|
|
254
348
|
test("rejects inverted from/to range with validation_error", async () => {
|
|
255
349
|
// from > to would silently return empty — confusing. Schema-level refine
|
|
256
350
|
// turns it into a clean 400 at the gate.
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { createConfigFeature } from "../../config";
|
|
17
17
|
import { createTenantFeature } from "../../tenant";
|
|
18
|
+
import { createUserFeature } from "../../user";
|
|
18
19
|
import { AuditQueries } from "../constants";
|
|
19
20
|
import { createEscapeHatchAuditSink, ESCAPE_HATCH_USED_EVENT } from "../escape-hatch-audit-sink";
|
|
20
21
|
import { createAuditFeature } from "../feature";
|
|
@@ -41,7 +42,13 @@ const adminOfSameTenant: SessionUser = createTestUser({ id: 10, tenantId, roles:
|
|
|
41
42
|
|
|
42
43
|
beforeAll(async () => {
|
|
43
44
|
stack = await setupTestStack({
|
|
44
|
-
features: [
|
|
45
|
+
features: [
|
|
46
|
+
probeFeature,
|
|
47
|
+
createConfigFeature(),
|
|
48
|
+
createTenantFeature(),
|
|
49
|
+
createUserFeature(),
|
|
50
|
+
createAuditFeature(),
|
|
51
|
+
],
|
|
45
52
|
extraContext: ({ db }) => ({ _escapeHatchAuditSink: createEscapeHatchAuditSink({ db }) }),
|
|
46
53
|
});
|
|
47
54
|
});
|
package/src/audit/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.292.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Audit log shows actor display names instead of raw UUIDs (fw#3103)",
|
|
6
|
+
"detail": "The `Actor` column of `audit:screen:audit-log` and the `createdBy` field of its detail screen rendered the raw `createdBy` UUID. The framework primitive for this already existed — `ListColumnSpec.refEntity` / `refLabelField`, used by `sessions` and `delivery` — the audit feature just did not declare it. Both now carry `refEntity: \"user:user\"`, `refLabelField: \"displayName\"`, which also makes the `createdBy → user:user` relation machine-readable in the app schema instead of implicit.\n`audit:query:list` and `audit:query:details` are unchanged and still return the plain id.\nA system write (`SYSTEM_USER_ID`, the null UUID) has no `read_users` row, so the bulk reference lookup can never resolve it. `SYSTEM_REFERENCE_LABELS` gained a `user:user` entry with the new `kumiko.reference.system-user` key, so every screen referencing `user:user` — not just the audit log — renders \"System\" for it. An actor that resolves to no row at all (deleted user) keeps the existing generic fallback: the raw id, no throw.\n`SYSTEM_USER_ID` moved from `framework/engine/system-user` to `kumiko-types/identifiers`, next to `SYSTEM_TENANT_ID`, so the client-side reference-label map can read it without importing a runtime module. `engine/system-user` re-exports it — every existing import keeps working.",
|
|
7
|
+
"migration": "The audit feature now declares `r.requires(\"tenant\", \"user\")`. An app that mounts `createAuditFeature()` without the `user` feature fails at boot with `Feature \"audit\" requires feature \"user\" which is not registered` — mount `createUserFeature()`. Apps using `securityBaselineFeatures()` already had to mount it."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.271.0",
|
|
4
11
|
"type": "improvement",
|
package/src/audit/feature.ts
CHANGED
|
@@ -35,8 +35,7 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
35
35
|
recommended: false,
|
|
36
36
|
});
|
|
37
37
|
r.translations({ keys: AUDIT_I18N });
|
|
38
|
-
|
|
39
|
-
r.requires("tenant");
|
|
38
|
+
r.requires("tenant", "user");
|
|
40
39
|
|
|
41
40
|
// Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
|
|
42
41
|
r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
|
|
@@ -57,10 +56,25 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
57
56
|
renderer: { format: "timestamp" },
|
|
58
57
|
},
|
|
59
58
|
{ field: "type", label: i18nKey("audit.log.col.type") },
|
|
60
|
-
{
|
|
59
|
+
{
|
|
60
|
+
field: "createdBy",
|
|
61
|
+
label: i18nKey("audit.log.col.actor"),
|
|
62
|
+
// Resolves only for SystemAdmin until fw#3107: the lookup QN this
|
|
63
|
+
// derives, user:query:user:list, is SystemAdmin-only.
|
|
64
|
+
refEntity: "user:user",
|
|
65
|
+
refLabelField: "displayName",
|
|
66
|
+
},
|
|
61
67
|
],
|
|
62
68
|
searchable: true,
|
|
63
69
|
defaultSort: { field: "createdAt", dir: "desc" },
|
|
70
|
+
facets: [
|
|
71
|
+
{
|
|
72
|
+
type: "dateRange",
|
|
73
|
+
field: "createdAt",
|
|
74
|
+
label: i18nKey("audit.log.col.when"),
|
|
75
|
+
params: { from: "from", to: "to" },
|
|
76
|
+
},
|
|
77
|
+
],
|
|
64
78
|
rowActions: [
|
|
65
79
|
{
|
|
66
80
|
kind: "navigate",
|
|
@@ -99,7 +113,7 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
99
113
|
{ field: "createdAt", renderer: { format: "timestamp" } },
|
|
100
114
|
"aggregateType",
|
|
101
115
|
"aggregateId",
|
|
102
|
-
"createdBy",
|
|
116
|
+
{ field: "createdBy", refEntity: "user:user", refLabelField: "displayName" },
|
|
103
117
|
"id",
|
|
104
118
|
],
|
|
105
119
|
},
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.291.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "loggedInHref callbacks on SignupCompleteScreen and InviteAcceptScreen now receive the roles granted by the flow",
|
|
6
|
+
"detail": "Apps can route a freshly activated user to a screen their role can actually open, instead of a fixed path that may render Access denied. The string form of loggedInHref is unchanged; the function form gains a roles field next to the existing tenantKey/tenantId."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.286.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -34,11 +34,40 @@ describe("retryAfterMinutes", () => {
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
describe("resolveLoggedInHref", () => {
|
|
37
|
+
type SignupArgs = { tenantKey: string; roles: readonly string[] };
|
|
38
|
+
|
|
37
39
|
test("string href returned as-is", () => {
|
|
38
|
-
expect(resolveLoggedInHref("/", "acme")).toBe("/");
|
|
40
|
+
expect(resolveLoggedInHref<SignupArgs>("/", { tenantKey: "acme", roles: ["User"] })).toBe("/");
|
|
39
41
|
});
|
|
40
42
|
|
|
41
43
|
test("function href receives tenantKey", () => {
|
|
42
|
-
expect(
|
|
44
|
+
expect(
|
|
45
|
+
resolveLoggedInHref<SignupArgs>(({ tenantKey }) => `/${tenantKey}/`, {
|
|
46
|
+
tenantKey: "acme",
|
|
47
|
+
roles: [],
|
|
48
|
+
}),
|
|
49
|
+
).toBe("/acme/");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("function href can route on the roles the server returned", () => {
|
|
53
|
+
const byRole = ({ tenantKey, roles }: SignupArgs): string =>
|
|
54
|
+
roles.includes("SystemAdmin") ? "/a/waitlist-list" : `/${tenantKey}/vehicle-start`;
|
|
55
|
+
|
|
56
|
+
expect(resolveLoggedInHref(byRole, { tenantKey: "acme", roles: ["Dealer"] })).toBe(
|
|
57
|
+
"/acme/vehicle-start",
|
|
58
|
+
);
|
|
59
|
+
expect(resolveLoggedInHref(byRole, { tenantKey: "acme", roles: ["SystemAdmin"] })).toBe(
|
|
60
|
+
"/a/waitlist-list",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("args object shape is per-screen — invite passes tenantId", () => {
|
|
65
|
+
expect(
|
|
66
|
+
resolveLoggedInHref(
|
|
67
|
+
({ tenantId, roles }: { tenantId: string; roles: readonly string[] }) =>
|
|
68
|
+
`/${tenantId}/${roles[0] ?? "none"}`,
|
|
69
|
+
{ tenantId: "t1", roles: ["Editor"] },
|
|
70
|
+
),
|
|
71
|
+
).toBe("/t1/Editor");
|
|
43
72
|
});
|
|
44
73
|
});
|
|
@@ -91,6 +91,53 @@ describe("InviteAcceptScreen — logged-in branch", () => {
|
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
test("branch 1: loggedInHref-Function bekommt die Rolle aus der invite-accept-Response", async () => {
|
|
95
|
+
globalThis.fetch = mock(
|
|
96
|
+
async () =>
|
|
97
|
+
new Response(
|
|
98
|
+
JSON.stringify({ tenantId: "tenant-new", role: "Dealer", alreadyMember: false }),
|
|
99
|
+
{ status: 200 },
|
|
100
|
+
),
|
|
101
|
+
) as unknown as typeof fetch;
|
|
102
|
+
const assign = mock<(url: string) => void>();
|
|
103
|
+
Object.defineProperty(window, "location", {
|
|
104
|
+
configurable: true,
|
|
105
|
+
writable: true,
|
|
106
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
renderWithProviders(
|
|
110
|
+
<InviteAcceptScreen
|
|
111
|
+
token="tok-123"
|
|
112
|
+
loggedInHref={({ tenantId, roles }) =>
|
|
113
|
+
roles.includes("SystemAdmin") ? "/a/waitlist-list" : `/${tenantId}/vehicle-start`
|
|
114
|
+
}
|
|
115
|
+
/>,
|
|
116
|
+
{ session: makeSessionApi({ status: "authenticated" }) },
|
|
117
|
+
);
|
|
118
|
+
fireEvent.click(screen.getByRole("button", { name: "Accept" }));
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(assign).toHaveBeenCalledWith("/tenant-new/vehicle-start");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("String-Form bleibt unverändert gültig", async () => {
|
|
125
|
+
const assign = mock<(url: string) => void>();
|
|
126
|
+
Object.defineProperty(window, "location", {
|
|
127
|
+
configurable: true,
|
|
128
|
+
writable: true,
|
|
129
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
renderWithProviders(<InviteAcceptScreen token="tok-123" loggedInHref="/fixed-landing" />, {
|
|
133
|
+
session: makeSessionApi({ status: "authenticated" }),
|
|
134
|
+
});
|
|
135
|
+
fireEvent.click(screen.getByRole("button", { name: "Accept" }));
|
|
136
|
+
await waitFor(() => {
|
|
137
|
+
expect(assign).toHaveBeenCalledWith("/fixed-landing");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
94
141
|
test("accept logged-in failure shows invalidInviteToken banner", async () => {
|
|
95
142
|
globalThis.fetch = mock(
|
|
96
143
|
async () => new Response(null, { status: 422 }),
|
|
@@ -154,6 +201,79 @@ describe("InviteAcceptScreen — anonymous branches", () => {
|
|
|
154
201
|
});
|
|
155
202
|
});
|
|
156
203
|
|
|
204
|
+
test("branch 2/3: die Session-Rollen aus user.roles schlagen die Invitation-Rolle", async () => {
|
|
205
|
+
globalThis.fetch = mock(
|
|
206
|
+
async () =>
|
|
207
|
+
new Response(
|
|
208
|
+
JSON.stringify({
|
|
209
|
+
token: "jwt",
|
|
210
|
+
user: { id: "u1", tenantId: "t1", roles: ["Dealer"] },
|
|
211
|
+
tenantId: "t1",
|
|
212
|
+
role: "SystemAdmin",
|
|
213
|
+
}),
|
|
214
|
+
{ status: 200 },
|
|
215
|
+
),
|
|
216
|
+
) as unknown as typeof fetch;
|
|
217
|
+
const assign = mock<(url: string) => void>();
|
|
218
|
+
Object.defineProperty(window, "location", {
|
|
219
|
+
configurable: true,
|
|
220
|
+
writable: true,
|
|
221
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
render(
|
|
225
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
226
|
+
<LocaleProvider resolver={resolver} fallbackBundles={[defaultTranslations]}>
|
|
227
|
+
<InviteAcceptScreen
|
|
228
|
+
token="tok-123"
|
|
229
|
+
loggedInHref={({ tenantId, roles }) => `/${tenantId}/${roles.join("+")}`}
|
|
230
|
+
/>
|
|
231
|
+
</LocaleProvider>
|
|
232
|
+
</PrimitivesProvider>,
|
|
233
|
+
);
|
|
234
|
+
fireEvent.change(screen.getByLabelText(/^Email/), { target: { value: "a@example.com" } });
|
|
235
|
+
fireEvent.change(screen.getByLabelText(/^Password/), { target: { value: "secret123" } });
|
|
236
|
+
fireEvent.click(screen.getByRole("button", { name: "Accept + sign in" }));
|
|
237
|
+
await waitFor(() => {
|
|
238
|
+
expect(assign).toHaveBeenCalledWith("/t1/Dealer");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("MFA-Challenge (200 ohne Rollen) liefert eine leere Rollenliste statt undefined", async () => {
|
|
243
|
+
globalThis.fetch = mock(
|
|
244
|
+
async () =>
|
|
245
|
+
new Response(JSON.stringify({ mfaRequired: true, challengeToken: "ch" }), { status: 200 }),
|
|
246
|
+
) as unknown as typeof fetch;
|
|
247
|
+
const assign = mock<(url: string) => void>();
|
|
248
|
+
Object.defineProperty(window, "location", {
|
|
249
|
+
configurable: true,
|
|
250
|
+
writable: true,
|
|
251
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
252
|
+
});
|
|
253
|
+
const seen: (readonly string[])[] = [];
|
|
254
|
+
|
|
255
|
+
render(
|
|
256
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
257
|
+
<LocaleProvider resolver={resolver} fallbackBundles={[defaultTranslations]}>
|
|
258
|
+
<InviteAcceptScreen
|
|
259
|
+
token="tok-123"
|
|
260
|
+
loggedInHref={({ roles }) => {
|
|
261
|
+
seen.push(roles);
|
|
262
|
+
return "/";
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
</LocaleProvider>
|
|
266
|
+
</PrimitivesProvider>,
|
|
267
|
+
);
|
|
268
|
+
fireEvent.change(screen.getByLabelText(/^Email/), { target: { value: "a@example.com" } });
|
|
269
|
+
fireEvent.change(screen.getByLabelText(/^Password/), { target: { value: "secret123" } });
|
|
270
|
+
fireEvent.click(screen.getByRole("button", { name: "Accept + sign in" }));
|
|
271
|
+
await waitFor(() => {
|
|
272
|
+
expect(assign).toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
expect(seen).toEqual([[]]);
|
|
275
|
+
});
|
|
276
|
+
|
|
157
277
|
test("anon failure shows invalidInviteToken banner", async () => {
|
|
158
278
|
globalThis.fetch = mock(
|
|
159
279
|
async () => new Response(null, { status: 422 }),
|
|
@@ -107,6 +107,78 @@ describe("SignupCompleteScreen", () => {
|
|
|
107
107
|
}
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
test("loggedInHref-Function bekommt die Rollen aus der signup-confirm-Response", async () => {
|
|
111
|
+
globalThis.fetch = mock(
|
|
112
|
+
async () =>
|
|
113
|
+
new Response(
|
|
114
|
+
JSON.stringify({
|
|
115
|
+
user: { id: "u1", tenantId: "t1", roles: ["Dealer"] },
|
|
116
|
+
tenantKey: "acme",
|
|
117
|
+
}),
|
|
118
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
119
|
+
),
|
|
120
|
+
) as unknown as typeof fetch;
|
|
121
|
+
|
|
122
|
+
renderWithProviders(
|
|
123
|
+
<SignupCompleteScreen
|
|
124
|
+
token="abc-token"
|
|
125
|
+
loggedInHref={({ tenantKey, roles }) =>
|
|
126
|
+
roles.includes("SystemAdmin") ? "/a/waitlist-list" : `/${tenantKey}/vehicle-start`
|
|
127
|
+
}
|
|
128
|
+
/>,
|
|
129
|
+
);
|
|
130
|
+
fillPasswords("validpass1", "validpass1");
|
|
131
|
+
fireEvent.click(screen.getByRole("button", { name: "Activate account" }));
|
|
132
|
+
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(screen.getByRole("link", { name: "Continue" }).getAttribute("href")).toBe(
|
|
135
|
+
"/acme/vehicle-start",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("gleiche Function, SystemAdmin-Rolle → anderes Ziel", async () => {
|
|
141
|
+
globalThis.fetch = mock(
|
|
142
|
+
async () =>
|
|
143
|
+
new Response(
|
|
144
|
+
JSON.stringify({
|
|
145
|
+
user: { id: "u1", tenantId: "t1", roles: ["SystemAdmin"] },
|
|
146
|
+
tenantKey: "acme",
|
|
147
|
+
}),
|
|
148
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
149
|
+
),
|
|
150
|
+
) as unknown as typeof fetch;
|
|
151
|
+
|
|
152
|
+
renderWithProviders(
|
|
153
|
+
<SignupCompleteScreen
|
|
154
|
+
token="abc-token"
|
|
155
|
+
loggedInHref={({ tenantKey, roles }) =>
|
|
156
|
+
roles.includes("SystemAdmin") ? "/a/waitlist-list" : `/${tenantKey}/vehicle-start`
|
|
157
|
+
}
|
|
158
|
+
/>,
|
|
159
|
+
);
|
|
160
|
+
fillPasswords("validpass1", "validpass1");
|
|
161
|
+
fireEvent.click(screen.getByRole("button", { name: "Activate account" }));
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(screen.getByRole("link", { name: "Continue" }).getAttribute("href")).toBe(
|
|
165
|
+
"/a/waitlist-list",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("String-Form bleibt unverändert gültig und ignoriert die Rollen", async () => {
|
|
171
|
+
renderWithProviders(<SignupCompleteScreen token="abc-token" loggedInHref="/fixed-landing" />);
|
|
172
|
+
fillPasswords("validpass1", "validpass1");
|
|
173
|
+
fireEvent.click(screen.getByRole("button", { name: "Activate account" }));
|
|
174
|
+
|
|
175
|
+
await waitFor(() => {
|
|
176
|
+
expect(screen.getByRole("link", { name: "Continue" }).getAttribute("href")).toBe(
|
|
177
|
+
"/fixed-landing",
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
110
182
|
test("mismatch → client-side error, kein fetch-Call", async () => {
|
|
111
183
|
const fetchMock = mock(async () => new Response(null, { status: 200 }));
|
|
112
184
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
@@ -21,10 +21,10 @@ export function retryAfterMinutes(retryAfterSeconds?: number): number | undefine
|
|
|
21
21
|
return Math.ceil(retryAfterSeconds / 60);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/** Resolves post-login redirect: string template or function of
|
|
25
|
-
export function resolveLoggedInHref(
|
|
26
|
-
href: string | ((args:
|
|
27
|
-
|
|
24
|
+
/** Resolves post-login redirect: string template or function of the screen's args. */
|
|
25
|
+
export function resolveLoggedInHref<Args>(
|
|
26
|
+
href: string | ((args: Args) => string),
|
|
27
|
+
args: Args,
|
|
28
28
|
): string {
|
|
29
|
-
return typeof href === "function" ? href(
|
|
29
|
+
return typeof href === "function" ? href(args) : href;
|
|
30
30
|
}
|