@cosmicdrift/kumiko-testing 0.312.0 → 0.314.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/README.md +17 -2
- package/package.json +4 -4
- package/src/changes.json +25 -0
- package/src/e2e/constants.ts +1 -0
- package/src/e2e/index.ts +1 -0
- package/src/e2e/screenshots.ts +112 -9
- package/src/e2e/seed-contract.ts +15 -10
- package/src/e2e/seed-route.ts +109 -26
- package/src/e2e/seeded-tenant-fixture.ts +14 -0
- package/src/seed-tenant.ts +16 -4
- package/src/seed-types.ts +22 -1
package/README.md
CHANGED
|
@@ -47,8 +47,17 @@ test("member sees the note", async ({ seedTenant, page }) => {
|
|
|
47
47
|
- The template also sets `KUMIKO_DEV_STYLESHEET_WATCH=0`, so the dev-server builds the app's CSS once and
|
|
48
48
|
never starts a Tailwind `--watch` process — that watcher would otherwise treat every Playwright artifact
|
|
49
49
|
write under `test-results/` as a rebuild trigger.
|
|
50
|
-
- App roles: `createE2eSeedRoutes({ extraRoles: ["TenantMember"] })` lets `tenant.addUser(["TenantMember"])` seed them
|
|
51
|
-
|
|
50
|
+
- App roles: `createE2eSeedRoutes({ extraRoles: ["TenantMember"] })` lets `tenant.addUser(["TenantMember"])` seed them.
|
|
51
|
+
- `tenant.addUser(["SystemAdmin"])` seeds a platform operator for SysAdmin screens: `SystemAdmin` lands as a
|
|
52
|
+
global user role, and the user joins the tenant as `Member` unless you also pass a tenant role
|
|
53
|
+
(`["TenantAdmin", "SystemAdmin"]`). The seed gate above is the only boundary around this.
|
|
54
|
+
- App seeders for test data the seeded users can't create:
|
|
55
|
+
`createE2eSeedRoutes({ extraSeeders: { checks: async (ctx, tenantId, body) => … } })`, called from a flow as
|
|
56
|
+
`await tenant.seed("checks", { days: 30 })`. The seeder gets `body` as `unknown` (validate it with a zod
|
|
57
|
+
`parse`; a `ZodError` becomes a 400) and a `ctx` whose `write`/`query` run as the tenant's system user
|
|
58
|
+
(SystemAdmin), bound to that one tenant, so the target handler must admit SystemAdmin (data no normal
|
|
59
|
+
handler produces, like backdated history, needs a SystemAdmin-only handler). There is no raw DB access.
|
|
60
|
+
Only tenants seeded by the same server's seed-tenant route are accepted; unknown seeder names are a 404.
|
|
52
61
|
- A `SeedPart` receives `{ tenant }` and works against the in-process `seedTenant` and the HTTP tenant alike.
|
|
53
62
|
- Already have a custom server entry (mail transport, KMS, boot seeds, ...) that also runs in prod?
|
|
54
63
|
Point `serverEntry` at it instead of duplicating `e2e/server.ts`, and mount the seed routes only
|
|
@@ -61,6 +70,12 @@ test("member sees the note", async ({ seedTenant, page }) => {
|
|
|
61
70
|
|
|
62
71
|
## Screenshots (`./e2e`)
|
|
63
72
|
|
|
73
|
+
Seeded identities are unique per run (`admin-<tenantId>@…`). To show a presentable address instead, the
|
|
74
|
+
flow registers the mapping once the tenant exists, and the runner replaces it in text and form values
|
|
75
|
+
right before every capture (again after each theme and viewport change); the seeded data stays as it is:
|
|
76
|
+
`presentIdentities([{ from: tenant.admin.email, to: "anna@example.com" }])` from the scenario fixtures, or
|
|
77
|
+
`captureScreenshot(page, name, { presentIdentities: [...] })` inline.
|
|
78
|
+
|
|
64
79
|
`runScreenshots`/`runMatrix` register their tests on the same `test` as above, so a scenario's `flow`
|
|
65
80
|
receives the seeded-tenant fixture too:
|
|
66
81
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.314.0",
|
|
4
4
|
"description": "Test template for Kumiko apps: per-flow seedTenant, app test stack, canonical test preloads, bunfig generator and integration runner.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -63,9 +63,9 @@
|
|
|
63
63
|
"kumiko-testing": "./bin/kumiko-testing.ts"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
67
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
68
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
66
|
+
"@cosmicdrift/kumiko-bundled-features": "0.314.0",
|
|
67
|
+
"@cosmicdrift/kumiko-dev-server": "0.314.0",
|
|
68
|
+
"@cosmicdrift/kumiko-framework": "0.314.0",
|
|
69
69
|
"zod": "^4.4.3"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.314.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Screenshot runner replaces generated seed identities with presentable values",
|
|
6
|
+
"detail": "Seeded identities are unique per run (admin-<tenantId>@…), so screenshots\nshowed a different address on every run. A scenario flow now calls\n`presentIdentities([{ from: tenant.admin.email, to: \"anna@example.com\" }])`\nfrom its fixtures, and runScreenshots/runMatrix replace `from` with `to` in\nthe page's text nodes and input/textarea values right before every capture,\nagain after each theme and viewport change. captureScreenshot takes the same\nlist as `opts.presentIdentities`. Only the rendered page changes: the seeded\ndata, the seed routes and the app UI stay untouched. Code that builds a\n`ScenarioFixtures` object itself (instead of receiving it in a flow) now has\nto pass `presentIdentities` too."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.314.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "captureScreenshot, runScreenshots and runMatrix settle after a reload or cross-document navigation",
|
|
12
|
+
"detail": "Chromium drops the old document's fetch/XHR requests on a reload, goto or\nlocation change without firing requestfinished or requestfailed. The\nin-flight tracker kept those requests forever, so a flow that reloaded\nwhile a data request was still open failed with \"page never settled\".\nThe tracker now clears its in-flight set when the main frame commits a new\ndocument (a main-frame navigation request followed by framenavigated).\nA same-document navigation (pushState) still waits for its requests, and\na navigation that never commits (204, download) keeps them too."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.314.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "Seed routes seed a SystemAdmin and run app-owned seeders",
|
|
18
|
+
"detail": "SystemAdmin is now one of the built-in seedable roles. This reverses the\nearlier \"SystemAdmin is never seedable\" rule: the seed gate\n(KUMIKO_TEST_SEED=1, never under NODE_ENV=production, per-run token) is now\nthe only boundary around the seed routes. `tenant.addUser([\"SystemAdmin\"])`\n(seed-user route and in-process seedTenant alike) stores SystemAdmin as a\nglobal user role, never as a membership role; without a tenant role the user\njoins the tenant as Member so the login has a membership.\n`createE2eSeedRoutes({ extraRoles: [\"SystemAdmin\"] })` no longer throws.\n`createE2eSeedRoutes({ extraSeeders: { name: (ctx, tenantId, body) => … } })`\nmounts POST /__test/seed behind the same gate; the flow calls\n`tenant.seed(name, body)` on the seedTenant fixture. Only tenants seeded by\nthat server's seed-tenant route are accepted (403 otherwise), unknown names\nare a 404, `body` arrives as `unknown` and a ZodError from the seeder becomes\na 400. `ctx.write`/`ctx.query` run as the tenant's system user (SystemAdmin)\nand are bound to that tenant, so the target handler must admit SystemAdmin;\nthere is no raw DB access."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.314.0",
|
|
22
|
+
"type": "breaking",
|
|
23
|
+
"title": "seed-user only reaches a tenant this server's seed-tenant route created",
|
|
24
|
+
"detail": "seed-user looked the tenant up via tenant:query:me and only rejected a tenant\nid that did not exist at all, so a tenant seeded by another server or\nin-process without this route's seed-tenant still got a user added. seed-user\nnow checks the same seededTenantIds set as /__test/seed and rejects any other\ntenant with the same 403.",
|
|
25
|
+
"migration": "Create the tenant via `seedTenant()` (the seed-tenant route) before adding users with `tenant.addUser`."
|
|
26
|
+
},
|
|
2
27
|
{
|
|
3
28
|
"version": "0.311.0",
|
|
4
29
|
"type": "breaking",
|
package/src/e2e/constants.ts
CHANGED
|
@@ -42,6 +42,7 @@ export const SEED_ROUTES = {
|
|
|
42
42
|
seedTenant: `${SEED_ROUTE_PREFIX}/seed-tenant`,
|
|
43
43
|
seedUser: `${SEED_ROUTE_PREFIX}/seed-user`,
|
|
44
44
|
inbox: `${SEED_ROUTE_PREFIX}/inbox`,
|
|
45
|
+
extraSeed: `${SEED_ROUTE_PREFIX}/seed`,
|
|
45
46
|
} as const;
|
|
46
47
|
|
|
47
48
|
// Desktop default for e2e runs and screenshots alike. Playwright's "Desktop
|
package/src/e2e/index.ts
CHANGED
package/src/e2e/screenshots.ts
CHANGED
|
@@ -32,11 +32,22 @@ export async function applyDefaultTheme(page: Page, theme: DefaultThemeId): Prom
|
|
|
32
32
|
}, theme);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Seeded identities are unique per run (admin-<tenantId>@…), so a screenshot
|
|
36
|
+
// would show a different address every time. Replaced in the page's text and
|
|
37
|
+
// form values right before each capture; the seeded data stays untouched.
|
|
38
|
+
export interface PresentIdentity {
|
|
39
|
+
readonly from: string;
|
|
40
|
+
readonly to: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
export interface ScenarioFixtures {
|
|
36
44
|
readonly seedTenant: SeedTenantFixture;
|
|
37
45
|
// runMatrix's current locale, for apps whose routes carry the locale in the
|
|
38
46
|
// path (kumiko:locale alone can't change the URL). Undefined in runScreenshots.
|
|
39
47
|
readonly locale?: string;
|
|
48
|
+
// Called from the flow once the identities exist; applies to every capture
|
|
49
|
+
// of the scenario, re-applied after each theme and viewport change.
|
|
50
|
+
readonly presentIdentities: (mappings: readonly PresentIdentity[]) => void;
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
export interface Scenario {
|
|
@@ -68,16 +79,41 @@ export interface Scenario {
|
|
|
68
79
|
const DATA_REQUEST_TYPES: ReadonlySet<string> = new Set(["fetch", "xhr"]);
|
|
69
80
|
const STABLE_POLLS = 2;
|
|
70
81
|
|
|
82
|
+
function isMainFrameNavigationRequest(page: Page, request: Request): boolean {
|
|
83
|
+
if (!request.isNavigationRequest() || request.serviceWorker() !== null) return false;
|
|
84
|
+
try {
|
|
85
|
+
return request.frame() === page.mainFrame();
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Chromium drops the old document's fetches on a cross-document navigation
|
|
92
|
+
// (reload, goto, location.href) without requestfinished or requestfailed, so
|
|
93
|
+
// they would stay in flight forever. framenavigated alone can't tell that commit
|
|
94
|
+
// apart from a pushState, which must keep them: only a framenavigated preceded by
|
|
95
|
+
// a main-frame navigation request is a new document.
|
|
71
96
|
function countInFlightDataRequests(page: Page): () => number {
|
|
72
97
|
const inFlight = new Set<Request>();
|
|
73
|
-
|
|
74
|
-
inFlight.delete(request);
|
|
75
|
-
};
|
|
98
|
+
let pendingDocumentNavigation: Request | undefined;
|
|
76
99
|
page.on("request", (request) => {
|
|
77
100
|
if (DATA_REQUEST_TYPES.has(request.resourceType())) inFlight.add(request);
|
|
101
|
+
else if (isMainFrameNavigationRequest(page, request)) pendingDocumentNavigation = request;
|
|
102
|
+
});
|
|
103
|
+
page.on("framenavigated", (frame) => {
|
|
104
|
+
if (frame === page.mainFrame() && pendingDocumentNavigation !== undefined) {
|
|
105
|
+
pendingDocumentNavigation = undefined;
|
|
106
|
+
inFlight.clear();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
page.on("requestfinished", (request) => {
|
|
110
|
+
inFlight.delete(request);
|
|
111
|
+
});
|
|
112
|
+
page.on("requestfailed", (request) => {
|
|
113
|
+
inFlight.delete(request);
|
|
114
|
+
// A navigation that never commits (204, download) leaves the old document alive.
|
|
115
|
+
if (request === pendingDocumentNavigation) pendingDocumentNavigation = undefined;
|
|
78
116
|
});
|
|
79
|
-
page.on("requestfinished", settle);
|
|
80
|
-
page.on("requestfailed", settle);
|
|
81
117
|
return () => inFlight.size;
|
|
82
118
|
}
|
|
83
119
|
|
|
@@ -112,6 +148,51 @@ async function waitForSettledPage(page: Page, inFlightDataRequests: () => number
|
|
|
112
148
|
.toBeGreaterThanOrEqual(STABLE_POLLS);
|
|
113
149
|
}
|
|
114
150
|
|
|
151
|
+
function assertPresentableMappings(mappings: readonly PresentIdentity[]): void {
|
|
152
|
+
for (const { from } of mappings) {
|
|
153
|
+
if (from === "") throw new Error("presentIdentities: `from` must not be empty");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Runs in the browser, so it may not reference module scope.
|
|
158
|
+
function replaceIdentitiesInDocument(mappings: readonly PresentIdentity[]): void {
|
|
159
|
+
const present = (text: string): string =>
|
|
160
|
+
mappings.reduce((current, { from, to }) => current.replaceAll(from, to), text);
|
|
161
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
162
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
163
|
+
const text = node.nodeValue ?? "";
|
|
164
|
+
const presented = present(text);
|
|
165
|
+
if (presented !== text) node.nodeValue = presented;
|
|
166
|
+
}
|
|
167
|
+
for (const field of document.querySelectorAll("input, textarea")) {
|
|
168
|
+
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) {
|
|
169
|
+
const presented = present(field.value);
|
|
170
|
+
if (presented !== field.value) field.value = presented;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function presentIdentitiesOnPage(
|
|
176
|
+
page: Page,
|
|
177
|
+
mappings: readonly PresentIdentity[],
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
if (mappings.length > 0) await page.evaluate(replaceIdentitiesInDocument, mappings);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function collectPresentIdentities(): {
|
|
183
|
+
readonly mappings: readonly PresentIdentity[];
|
|
184
|
+
readonly register: (mappings: readonly PresentIdentity[]) => void;
|
|
185
|
+
} {
|
|
186
|
+
const collected: PresentIdentity[] = [];
|
|
187
|
+
return {
|
|
188
|
+
mappings: collected,
|
|
189
|
+
register: (mappings) => {
|
|
190
|
+
assertPresentableMappings(mappings);
|
|
191
|
+
collected.push(...mappings);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
115
196
|
async function openScenario(
|
|
116
197
|
page: Page,
|
|
117
198
|
s: Scenario,
|
|
@@ -179,9 +260,14 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
|
|
|
179
260
|
await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
|
|
180
261
|
if (opts.pinLocale) await pinEnglishLocale(page);
|
|
181
262
|
const inFlightDataRequests = countInFlightDataRequests(page);
|
|
263
|
+
const identities = collectPresentIdentities();
|
|
182
264
|
if (s.viewport) await page.setViewportSize(s.viewport);
|
|
183
|
-
await openScenario(page, s, inFlightDataRequests, {
|
|
265
|
+
await openScenario(page, s, inFlightDataRequests, {
|
|
266
|
+
seedTenant,
|
|
267
|
+
presentIdentities: identities.register,
|
|
268
|
+
});
|
|
184
269
|
if (s.beforeCapture) await s.beforeCapture(page);
|
|
270
|
+
await presentIdentitiesOnPage(page, identities.mappings);
|
|
185
271
|
const path = `${outDir}/${s.name}.png`;
|
|
186
272
|
await page.screenshot({
|
|
187
273
|
path,
|
|
@@ -400,7 +486,12 @@ export function runMatrix<T extends string>(
|
|
|
400
486
|
localStorage.removeItem("kumiko:theme");
|
|
401
487
|
}, locale);
|
|
402
488
|
const inFlightDataRequests = countInFlightDataRequests(page);
|
|
403
|
-
|
|
489
|
+
const identities = collectPresentIdentities();
|
|
490
|
+
await openScenario(page, s, inFlightDataRequests, {
|
|
491
|
+
seedTenant,
|
|
492
|
+
locale,
|
|
493
|
+
presentIdentities: identities.register,
|
|
494
|
+
});
|
|
404
495
|
|
|
405
496
|
const digests: ThemeScreenshotDigest<T>[] = [];
|
|
406
497
|
const projectBaseDir =
|
|
@@ -412,6 +503,7 @@ export function runMatrix<T extends string>(
|
|
|
412
503
|
if (plan.mode === "desktop") await page.setViewportSize(VIEWPORTS[vp]);
|
|
413
504
|
await waitForSettledPage(page, inFlightDataRequests);
|
|
414
505
|
if (s.beforeCapture) await s.beforeCapture(page);
|
|
506
|
+
await presentIdentitiesOnPage(page, identities.mappings);
|
|
415
507
|
const dir = `${projectBaseDir}/${s.name}/${locale}/${theme}`;
|
|
416
508
|
mkdirSync(dir, { recursive: true });
|
|
417
509
|
const path = `${dir}/${vp}.png`;
|
|
@@ -470,6 +562,7 @@ export type CaptureFit = "viewport" | "fullPage" | "content";
|
|
|
470
562
|
export interface CaptureScreenshotOptions {
|
|
471
563
|
readonly reducedMotion?: ReducedMotionOption;
|
|
472
564
|
readonly fit?: CaptureFit;
|
|
565
|
+
readonly presentIdentities?: readonly PresentIdentity[];
|
|
473
566
|
}
|
|
474
567
|
|
|
475
568
|
const CONTENT_FIT_MAX_ROUNDS = 4;
|
|
@@ -530,21 +623,31 @@ export async function captureScreenshot(
|
|
|
530
623
|
// skip: a plain e2e run without SCREENSHOT_DIR must not write screenshots.
|
|
531
624
|
if (dir === undefined || dir === "") return;
|
|
532
625
|
const fit = opts.fit ?? "viewport";
|
|
626
|
+
const identities = opts.presentIdentities ?? [];
|
|
627
|
+
assertPresentableMappings(identities);
|
|
533
628
|
await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
|
|
534
629
|
await waitForSettledPage(page, inFlightTrackerFor(page));
|
|
535
630
|
const path = `${dir}/${name}.png`;
|
|
536
631
|
mkdirSync(dirname(path), { recursive: true });
|
|
537
|
-
|
|
632
|
+
// Before the content fit too: a presented value of another length can change the overflow.
|
|
633
|
+
await presentIdentitiesOnPage(page, identities);
|
|
634
|
+
if (fit === "content") await captureGrownToContent(page, name, path, identities);
|
|
538
635
|
else await page.screenshot({ path, animations: "disabled", fullPage: fit === "fullPage" });
|
|
539
636
|
}
|
|
540
637
|
|
|
541
|
-
async function captureGrownToContent(
|
|
638
|
+
async function captureGrownToContent(
|
|
639
|
+
page: Page,
|
|
640
|
+
name: string,
|
|
641
|
+
path: string,
|
|
642
|
+
identities: readonly PresentIdentity[],
|
|
643
|
+
): Promise<void> {
|
|
542
644
|
const viewport = page.viewportSize();
|
|
543
645
|
if (viewport === null) {
|
|
544
646
|
throw new Error(`captureScreenshot(${name}): fit "content" needs a page with a viewport`);
|
|
545
647
|
}
|
|
546
648
|
try {
|
|
547
649
|
await growViewportToContent(page, name, viewport.width);
|
|
650
|
+
await presentIdentitiesOnPage(page, identities);
|
|
548
651
|
await page.screenshot({ path, animations: "disabled" });
|
|
549
652
|
} finally {
|
|
550
653
|
// Later assertions in the same test must run against the original window.
|
package/src/e2e/seed-contract.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// SystemAdmin is seedable: the seed gate (KUMIKO_TEST_SEED=1, never
|
|
5
|
+
// under NODE_ENV=production, per-run token) is the only boundary around these
|
|
6
|
+
// routes. It lands as a global user role, never as a membership role.
|
|
7
|
+
export const SEEDABLE_ROLES = [ROLES.TenantAdmin, ROLES.Member, ROLES.SystemAdmin] as const;
|
|
5
8
|
export const MAX_SEED_MEMBERS = 10;
|
|
6
9
|
export const MAX_TENANT_NAME_LENGTH = 100;
|
|
7
10
|
const MAX_EMAIL_LENGTH = 320;
|
|
@@ -34,10 +37,6 @@ export const seedTenantResponseSchema = z.object({
|
|
|
34
37
|
members: z.array(seededCredentialsSchema),
|
|
35
38
|
});
|
|
36
39
|
|
|
37
|
-
function isSystemAdminRole(role: string): boolean {
|
|
38
|
-
return role.trim().toLowerCase() === ROLES.SystemAdmin.toLowerCase();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
40
|
export function resolveSeedableRoles(extraRoles: readonly string[] = []): readonly string[] {
|
|
42
41
|
for (const role of extraRoles) {
|
|
43
42
|
if (typeof role !== "string" || role.trim() === "") {
|
|
@@ -45,11 +44,6 @@ export function resolveSeedableRoles(extraRoles: readonly string[] = []): readon
|
|
|
45
44
|
`createE2eSeedRoutes: extraRoles entries must be non-empty strings, got ${JSON.stringify(role)}`,
|
|
46
45
|
);
|
|
47
46
|
}
|
|
48
|
-
if (isSystemAdminRole(role)) {
|
|
49
|
-
throw new Error(
|
|
50
|
-
`createE2eSeedRoutes: extraRoles must not contain ${ROLES.SystemAdmin}; it is never seedable`,
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
47
|
}
|
|
54
48
|
return [...new Set<string>([...SEEDABLE_ROLES, ...extraRoles])];
|
|
55
49
|
}
|
|
@@ -73,6 +67,16 @@ export const seedUserRequestSchema = createSeedUserRequestSchema();
|
|
|
73
67
|
|
|
74
68
|
export const seedUserResponseSchema = seededCredentialsSchema;
|
|
75
69
|
|
|
70
|
+
const MAX_SEEDER_NAME_LENGTH = 100;
|
|
71
|
+
|
|
72
|
+
export const extraSeedRequestSchema = z.strictObject({
|
|
73
|
+
tenantId: z.uuid(),
|
|
74
|
+
seeder: z.string().min(1).max(MAX_SEEDER_NAME_LENGTH),
|
|
75
|
+
body: z.unknown().optional(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export const extraSeedResponseSchema = z.object({ result: z.unknown() });
|
|
79
|
+
|
|
76
80
|
export const inboxQuerySchema = z.strictObject({
|
|
77
81
|
tenantId: z.uuid().optional(),
|
|
78
82
|
to: z.string().min(1).max(MAX_EMAIL_LENGTH),
|
|
@@ -92,3 +96,4 @@ export const inboxResponseSchema = z.object({ messages: z.array(capturedMailSche
|
|
|
92
96
|
export type CapturedMail = z.infer<typeof capturedMailSchema>;
|
|
93
97
|
export type SeedTenantResponse = z.infer<typeof seedTenantResponseSchema>;
|
|
94
98
|
export type SeedUserRequest = z.infer<typeof seedUserRequestSchema>;
|
|
99
|
+
export type ExtraSeedRequest = z.infer<typeof extraSeedRequestSchema>;
|
package/src/e2e/seed-route.ts
CHANGED
|
@@ -4,24 +4,26 @@ import {
|
|
|
4
4
|
getInbox,
|
|
5
5
|
mailTransportInMemoryFeature,
|
|
6
6
|
} from "@cosmicdrift/kumiko-bundled-features/mail-transport-inmemory";
|
|
7
|
-
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
8
7
|
import {
|
|
9
8
|
type ExtraRouteDefinition,
|
|
10
9
|
ExtraRouteRejection,
|
|
11
10
|
type SignatureExtraRouteVerifyRequest,
|
|
12
11
|
signatureRoute,
|
|
13
12
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
14
|
-
import type
|
|
13
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import * as z from "zod";
|
|
15
15
|
import {
|
|
16
16
|
persistTenantRows,
|
|
17
17
|
persistUserRows,
|
|
18
18
|
type SeedWriter,
|
|
19
19
|
unwrapSavedRow,
|
|
20
|
+
unwrapWriteData,
|
|
20
21
|
} from "../seed-tenant";
|
|
21
22
|
import { SEED_ENABLE_ENV, SEED_ROUTES, SEED_TOKEN_ENV, SEED_TOKEN_HEADER } from "./constants";
|
|
22
23
|
import {
|
|
23
24
|
type CapturedMail,
|
|
24
25
|
createSeedUserRequestSchema,
|
|
26
|
+
extraSeedRequestSchema,
|
|
25
27
|
inboxQuerySchema,
|
|
26
28
|
seedTenantRequestSchema,
|
|
27
29
|
} from "./seed-contract";
|
|
@@ -30,13 +32,16 @@ type ParsedBody<T> =
|
|
|
30
32
|
| { readonly success: true; readonly data: T }
|
|
31
33
|
| { readonly success: false; readonly error: string };
|
|
32
34
|
|
|
35
|
+
function formatIssues(error: z.ZodError): string {
|
|
36
|
+
return error.issues
|
|
37
|
+
.map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`)
|
|
38
|
+
.join("; ");
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
function parseWith<S extends z.ZodType>(schema: S, value: unknown): ParsedBody<z.infer<S>> {
|
|
34
42
|
const result = schema.safeParse(value);
|
|
35
43
|
if (result.success) return { success: true, data: result.data };
|
|
36
|
-
|
|
37
|
-
.map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`)
|
|
38
|
-
.join("; ");
|
|
39
|
-
return { success: false, error };
|
|
44
|
+
return { success: false, error: formatIssues(result.error) };
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
function parseJsonBody<S extends z.ZodType>(schema: S, raw: string): ParsedBody<z.infer<S>> {
|
|
@@ -89,8 +94,44 @@ function assertGateOpen(request: SignatureExtraRouteVerifyRequest): void {
|
|
|
89
94
|
}
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
// Shared by seed-user and extra-seed: both address a tenant by id, and a
|
|
98
|
+
// tenant exists in seededTenantIds by construction only after this same
|
|
99
|
+
// server's seed-tenant route created it.
|
|
100
|
+
function assertSeededTenant(tenantId: string, seededTenantIds: ReadonlySet<string>): void {
|
|
101
|
+
if (!seededTenantIds.has(tenantId)) {
|
|
102
|
+
throw new ExtraRouteRejection(403, {
|
|
103
|
+
error: `tenant ${tenantId} was not seeded by this server's seed-tenant route`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Every call runs with system privileges inside the one seeded tenant the
|
|
109
|
+
// route verified; there is no way to address another tenant through it.
|
|
110
|
+
export type E2eSeederContext = {
|
|
111
|
+
readonly write: (handlerQn: string, payload: unknown) => Promise<unknown>;
|
|
112
|
+
readonly query: (handlerQn: string, payload: unknown) => Promise<unknown>;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// `body` is whatever the client sent; validate it with a zod schema's parse(),
|
|
116
|
+
// a ZodError becomes a 400. The return value goes back as JSON `{ result }`.
|
|
117
|
+
export type E2eExtraSeeder = (
|
|
118
|
+
ctx: E2eSeederContext,
|
|
119
|
+
tenantId: TenantId,
|
|
120
|
+
body: unknown,
|
|
121
|
+
) => Promise<unknown>;
|
|
122
|
+
|
|
123
|
+
type VerifiedExtraSeed = {
|
|
124
|
+
readonly seederName: string;
|
|
125
|
+
readonly seeder: E2eExtraSeeder;
|
|
126
|
+
readonly tenantId: TenantId;
|
|
127
|
+
readonly body: unknown;
|
|
128
|
+
};
|
|
129
|
+
|
|
92
130
|
export type E2eSeedRoutesOptions = {
|
|
93
131
|
readonly extraRoles?: readonly string[];
|
|
132
|
+
// Reachable only through tenant.seed(name, body) and only for tenants the
|
|
133
|
+
// seed-tenant route of this same server seeded.
|
|
134
|
+
readonly extraSeeders?: Readonly<Record<string, E2eExtraSeeder>>;
|
|
94
135
|
// Apps that send tenantless mail (signup/forgot-password/magic-link) via
|
|
95
136
|
// their own raw createInMemoryTransport() pass its `sent` array here so
|
|
96
137
|
// the inbox route can read it without a tenantId. Independent of
|
|
@@ -120,6 +161,11 @@ export function createE2eSeedRoutes(
|
|
|
120
161
|
options: E2eSeedRoutesOptions = {},
|
|
121
162
|
): readonly ExtraRouteDefinition[] {
|
|
122
163
|
const seedUserRequestSchema = createSeedUserRequestSchema(options.extraRoles);
|
|
164
|
+
const extraSeeders = options.extraSeeders ?? {};
|
|
165
|
+
// In-memory on purpose: the seed token is known to every test client, so
|
|
166
|
+
// only server-side state can tell a tenant this server seeded from any
|
|
167
|
+
// other tenant id a client might send.
|
|
168
|
+
const seededTenantIds = new Set<string>();
|
|
123
169
|
|
|
124
170
|
const seedTenantRoute = signatureRoute<z.infer<typeof seedTenantRequestSchema>>({
|
|
125
171
|
method: "POST",
|
|
@@ -133,13 +179,13 @@ export function createE2eSeedRoutes(
|
|
|
133
179
|
const write: SeedWriter = async (handlerQn, payload, tenantId) =>
|
|
134
180
|
unwrapSavedRow(handlerQn, await deps.dispatchSystemWrite({ handlerQn, payload, tenantId }));
|
|
135
181
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
);
|
|
182
|
+
const persisted = await persistTenantRows(write, {
|
|
183
|
+
name: verified.name,
|
|
184
|
+
users: verified.members,
|
|
185
|
+
admin: verified.admin,
|
|
186
|
+
});
|
|
187
|
+
seededTenantIds.add(persisted.id);
|
|
188
|
+
return c.json(persisted);
|
|
143
189
|
} catch (error) {
|
|
144
190
|
return c.json({ error: error instanceof Error ? error.message : "seed failed" }, 500);
|
|
145
191
|
}
|
|
@@ -152,20 +198,11 @@ export function createE2eSeedRoutes(
|
|
|
152
198
|
entry: "signature",
|
|
153
199
|
verify: async (request) => {
|
|
154
200
|
assertGateOpen(request);
|
|
155
|
-
|
|
201
|
+
const parsed = parseOrReject(parseJsonBody(seedUserRequestSchema, request.rawBody));
|
|
202
|
+
assertSeededTenant(parsed.tenantId, seededTenantIds);
|
|
203
|
+
return parsed;
|
|
156
204
|
},
|
|
157
205
|
handler: async (c, verified, deps) => {
|
|
158
|
-
// No raw db in signature-route deps (fw#3050) — tenant:query:me, run as
|
|
159
|
-
// a SystemAdmin scoped to verified.tenantId, is the existing
|
|
160
|
-
// fetchOne(tenantTable, {id: tenantId}) lookup, just behind the dispatcher.
|
|
161
|
-
const tenantRow = await deps.dispatchSystemQuery({
|
|
162
|
-
handlerQn: TenantQueries.me,
|
|
163
|
-
payload: {},
|
|
164
|
-
tenantId: verified.tenantId,
|
|
165
|
-
});
|
|
166
|
-
if (tenantRow === null) {
|
|
167
|
-
return c.json({ error: `unknown tenant ${verified.tenantId}` }, 404);
|
|
168
|
-
}
|
|
169
206
|
const write: SeedWriter = async (handlerQn, payload, tenantId) =>
|
|
170
207
|
unwrapSavedRow(handlerQn, await deps.dispatchSystemWrite({ handlerQn, payload, tenantId }));
|
|
171
208
|
try {
|
|
@@ -225,5 +262,51 @@ export function createE2eSeedRoutes(
|
|
|
225
262
|
},
|
|
226
263
|
});
|
|
227
264
|
|
|
228
|
-
|
|
265
|
+
const extraSeedRoute = signatureRoute<VerifiedExtraSeed>({
|
|
266
|
+
method: "POST",
|
|
267
|
+
path: SEED_ROUTES.extraSeed,
|
|
268
|
+
entry: "signature",
|
|
269
|
+
verify: async (request) => {
|
|
270
|
+
assertGateOpen(request);
|
|
271
|
+
const parsed = parseOrReject(parseJsonBody(extraSeedRequestSchema, request.rawBody));
|
|
272
|
+
const seeder = Object.hasOwn(extraSeeders, parsed.seeder)
|
|
273
|
+
? extraSeeders[parsed.seeder]
|
|
274
|
+
: undefined;
|
|
275
|
+
if (seeder === undefined) {
|
|
276
|
+
const registered = Object.keys(extraSeeders).join(", ") || "none";
|
|
277
|
+
throw new ExtraRouteRejection(404, {
|
|
278
|
+
error: `unknown seeder "${parsed.seeder}"; registered: ${registered}`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
assertSeededTenant(parsed.tenantId, seededTenantIds);
|
|
282
|
+
return {
|
|
283
|
+
seederName: parsed.seeder,
|
|
284
|
+
seeder,
|
|
285
|
+
tenantId: parsed.tenantId,
|
|
286
|
+
body: parsed.body,
|
|
287
|
+
};
|
|
288
|
+
},
|
|
289
|
+
handler: async (c, verified, deps) => {
|
|
290
|
+
const { tenantId } = verified;
|
|
291
|
+
const seederContext: E2eSeederContext = {
|
|
292
|
+
write: async (handlerQn, payload) =>
|
|
293
|
+
unwrapWriteData(
|
|
294
|
+
handlerQn,
|
|
295
|
+
await deps.dispatchSystemWrite({ handlerQn, payload, tenantId }),
|
|
296
|
+
),
|
|
297
|
+
query: (handlerQn, payload) => deps.dispatchSystemQuery({ handlerQn, payload, tenantId }),
|
|
298
|
+
};
|
|
299
|
+
try {
|
|
300
|
+
const result = await verified.seeder(seederContext, tenantId, verified.body);
|
|
301
|
+
return c.json({ result: result ?? null });
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if (error instanceof z.ZodError) {
|
|
304
|
+
return c.json({ error: `seeder "${verified.seederName}": ${formatIssues(error)}` }, 400);
|
|
305
|
+
}
|
|
306
|
+
return c.json({ error: error instanceof Error ? error.message : "seed failed" }, 500);
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
return [seedTenantRoute, seedUserRoute, inboxRoute, extraSeedRoute];
|
|
229
312
|
}
|
|
@@ -20,6 +20,8 @@ import { clearSession, createHttpApi, loginViaApi } from "./auth-kit";
|
|
|
20
20
|
import { SEED_ROUTES } from "./constants";
|
|
21
21
|
import { seedRouteHeaders } from "./mail-capture";
|
|
22
22
|
import {
|
|
23
|
+
type ExtraSeedRequest,
|
|
24
|
+
extraSeedResponseSchema,
|
|
23
25
|
type SeedUserRequest,
|
|
24
26
|
seedTenantResponseSchema,
|
|
25
27
|
seedUserResponseSchema,
|
|
@@ -29,6 +31,9 @@ export type E2eSeedTenantOptions = Omit<SeedTenantOptions, "persist">;
|
|
|
29
31
|
|
|
30
32
|
export type E2eSeededTenant = SeededTenant & {
|
|
31
33
|
readonly loginAs: (page: Page, user: SeededUser) => Promise<void>;
|
|
34
|
+
// Runs an app seeder registered via createE2eSeedRoutes({ extraSeeders })
|
|
35
|
+
// inside this tenant and resolves to its JSON result, unvalidated.
|
|
36
|
+
readonly seed: (seeder: string, body?: unknown) => Promise<unknown>;
|
|
32
37
|
};
|
|
33
38
|
|
|
34
39
|
export type SeedTenantFixture = (opts?: E2eSeedTenantOptions) => Promise<E2eSeededTenant>;
|
|
@@ -133,6 +138,15 @@ export async function provideSeedTenant(
|
|
|
133
138
|
api: httpApiFor(admin),
|
|
134
139
|
apiAs: httpApiFor,
|
|
135
140
|
loginAs,
|
|
141
|
+
seed: async (seeder, body) =>
|
|
142
|
+
(
|
|
143
|
+
await postSeedRoute(
|
|
144
|
+
request,
|
|
145
|
+
SEED_ROUTES.extraSeed,
|
|
146
|
+
{ tenantId, seeder, body } satisfies ExtraSeedRequest,
|
|
147
|
+
extraSeedResponseSchema,
|
|
148
|
+
)
|
|
149
|
+
).result,
|
|
136
150
|
};
|
|
137
151
|
|
|
138
152
|
for (const part of opts.with ?? []) await part({ tenant });
|
package/src/seed-tenant.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type SeededCredentials,
|
|
18
18
|
type SeededTenant,
|
|
19
19
|
type SeedTenantOptions,
|
|
20
|
+
splitSeedRoles,
|
|
20
21
|
withSession,
|
|
21
22
|
} from "./seed-types";
|
|
22
23
|
|
|
@@ -81,14 +82,19 @@ function isSavedRow(value: unknown): value is SavedRow {
|
|
|
81
82
|
return isPlainObject(data) && typeof data["version"] === "number";
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
export function
|
|
85
|
+
export function unwrapWriteData(handlerQn: string, result: WriteResult): unknown {
|
|
85
86
|
if (!result.isSuccess) {
|
|
86
87
|
throw new Error(`seedTenant: ${handlerQn} failed: ${JSON.stringify(result.error)}`);
|
|
87
88
|
}
|
|
88
|
-
|
|
89
|
+
return result.data;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function unwrapSavedRow(handlerQn: string, result: WriteResult): SavedRow {
|
|
93
|
+
const data = unwrapWriteData(handlerQn, result);
|
|
94
|
+
if (!isSavedRow(data)) {
|
|
89
95
|
throw new Error(`seedTenant: ${handlerQn} returned no saved row`);
|
|
90
96
|
}
|
|
91
|
-
return
|
|
97
|
+
return data;
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
export function stackSeedWriter(stack: TestStack): SeedWriter {
|
|
@@ -112,12 +118,14 @@ export async function persistUserRows(
|
|
|
112
118
|
const light = lightCredentials(
|
|
113
119
|
identity.email !== undefined ? substituteTenantId(identity.email, tenantId) : undefined,
|
|
114
120
|
);
|
|
121
|
+
const { globalRoles, membershipRoles } = splitSeedRoles(roles);
|
|
115
122
|
const created = await write(
|
|
116
123
|
UserHandlers.create,
|
|
117
124
|
{
|
|
118
125
|
email: light.email,
|
|
119
126
|
passwordHash: await hashPassword(light.password),
|
|
120
127
|
displayName: identity.displayName ?? `Seed ${light.id.slice(0, 8)}`,
|
|
128
|
+
...(globalRoles.length > 0 ? { roles: [...globalRoles] } : {}),
|
|
121
129
|
},
|
|
122
130
|
tenantId,
|
|
123
131
|
);
|
|
@@ -126,7 +134,11 @@ export async function persistUserRows(
|
|
|
126
134
|
{ id: created.id, version: created.data.version, changes: { emailVerified: true } },
|
|
127
135
|
tenantId,
|
|
128
136
|
);
|
|
129
|
-
await write(
|
|
137
|
+
await write(
|
|
138
|
+
TenantHandlers.addMember,
|
|
139
|
+
{ userId: created.id, tenantId, roles: membershipRoles },
|
|
140
|
+
tenantId,
|
|
141
|
+
);
|
|
130
142
|
return { id: created.id, email: light.email, password: light.password };
|
|
131
143
|
}
|
|
132
144
|
|
package/src/seed-types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
1
2
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import type { WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
|
|
@@ -49,10 +50,30 @@ export type SeededTenant = {
|
|
|
49
50
|
readonly apiAs: (user: SeededUser) => BoundApi;
|
|
50
51
|
};
|
|
51
52
|
|
|
53
|
+
export type SeedRoleSplit = {
|
|
54
|
+
readonly globalRoles: readonly string[];
|
|
55
|
+
readonly membershipRoles: readonly string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// SystemAdmin only counts as a global user role (login strips it from
|
|
59
|
+
// membership roles), yet login still needs a membership: a SystemAdmin
|
|
60
|
+
// without a tenant role therefore also joins the tenant as Member.
|
|
61
|
+
export function splitSeedRoles(roles: readonly string[]): SeedRoleSplit {
|
|
62
|
+
const globalRoles = roles.filter((role) => role === ROLES.SystemAdmin);
|
|
63
|
+
const tenantRoles = roles.filter((role) => role !== ROLES.SystemAdmin);
|
|
64
|
+
const membershipRoles =
|
|
65
|
+
globalRoles.length > 0 && tenantRoles.length === 0 ? [ROLES.Member] : tenantRoles;
|
|
66
|
+
return { globalRoles, membershipRoles };
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
export function withSession(
|
|
53
70
|
credentials: SeededCredentials,
|
|
54
71
|
tenantId: TenantId,
|
|
55
72
|
roles: readonly string[],
|
|
56
73
|
): SeededUser {
|
|
57
|
-
|
|
74
|
+
const { globalRoles, membershipRoles } = splitSeedRoles(roles);
|
|
75
|
+
return {
|
|
76
|
+
...credentials,
|
|
77
|
+
session: { id: credentials.id, tenantId, roles: [...globalRoles, ...membershipRoles] },
|
|
78
|
+
};
|
|
58
79
|
}
|