@cosmicdrift/kumiko-testing 0.313.0 → 0.315.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 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
- `SystemAdmin` is never seedable and makes the route builder throw.
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.313.0",
3
+ "version": "0.315.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.313.0",
67
- "@cosmicdrift/kumiko-dev-server": "0.313.0",
68
- "@cosmicdrift/kumiko-framework": "0.313.0",
66
+ "@cosmicdrift/kumiko-bundled-features": "0.315.0",
67
+ "@cosmicdrift/kumiko-dev-server": "0.315.0",
68
+ "@cosmicdrift/kumiko-framework": "0.315.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",
@@ -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
@@ -47,6 +47,7 @@ export {
47
47
  type FlatOptions,
48
48
  findIdenticalThemeScreenshots,
49
49
  type MatrixOptions,
50
+ type PresentIdentity,
50
51
  runMatrix,
51
52
  runScreenshots,
52
53
  type Scenario,
@@ -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
- const settle = (request: Request): void => {
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, { seedTenant });
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
- await openScenario(page, s, inFlightDataRequests, { seedTenant, locale });
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
- if (fit === "content") await captureGrownToContent(page, name, path);
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(page: Page, name: string, path: string): Promise<void> {
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.
@@ -1,7 +1,10 @@
1
1
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
2
2
  import * as z from "zod";
3
3
 
4
- export const SEEDABLE_ROLES = [ROLES.TenantAdmin, ROLES.Member] as const;
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>;
@@ -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 * as z from "zod";
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
- const error = result.error.issues
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
- return c.json(
137
- await persistTenantRows(write, {
138
- name: verified.name,
139
- users: verified.members,
140
- admin: verified.admin,
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
- return parseOrReject(parseJsonBody(seedUserRequestSchema, request.rawBody));
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
- return [seedTenantRoute, seedUserRoute, inboxRoute];
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 });
@@ -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 unwrapSavedRow(handlerQn: string, result: WriteResult): SavedRow {
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
- if (!isSavedRow(result.data)) {
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 result.data;
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(TenantHandlers.addMember, { userId: created.id, tenantId, roles }, tenantId);
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
- return { ...credentials, session: { id: credentials.id, tenantId, roles } };
74
+ const { globalRoles, membershipRoles } = splitSeedRoles(roles);
75
+ return {
76
+ ...credentials,
77
+ session: { id: credentials.id, tenantId, roles: [...globalRoles, ...membershipRoles] },
78
+ };
58
79
  }