@cosmicdrift/kumiko-testing 0.319.0 → 0.321.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
@@ -11,12 +11,14 @@ const persisted = await seedTenant(stack, { persist: true }); // real rows via t
11
11
  await tenant.api.writeOk("my-feature:write:thing:create", { title: "x" });
12
12
  ```
13
13
 
14
- - `preload/{temporal,ci-log,scrub-env,env,real,dom}`: bunfig `preload` entries. `scrub-env` removes
15
- `PROVIDER_ENV_KEYS` (unit default); `env` adds the localhost service defaults (integration);
16
- `real` refuses to run without `KUMIKO_REAL_PROVIDERS=1` and in CI, and keeps the keys; `dom`
17
- registers happy-dom (window/document/HTMLElement) while preserving Bun's native
18
- fetch/Request/Response, and cleans up (`@testing-library/react` unmount, Radix DOM/style leaks,
19
- `window.location`) after every test.
14
+ - `preload/{temporal,ci-log,scrub-env,schema-env-defaults,env,real,dom}`: bunfig `preload` entries.
15
+ `scrub-env` removes `PROVIDER_ENV_KEYS` (unit default); `schema-env-defaults` fills `JWT_SECRET`
16
+ and the `KUMIKO_SECRETS_MASTER_KEY_*` pair that the framework's own env schema requires (unit,
17
+ DOM, integration, real — never a service endpoint); `env` adds `schema-env-defaults` plus the
18
+ localhost service defaults (integration); `real` refuses to run without
19
+ `KUMIKO_REAL_PROVIDERS=1` and in CI, and keeps the keys; `dom` registers happy-dom
20
+ (window/document/HTMLElement) while preserving Bun's native fetch/Request/Response, and cleans up
21
+ (`@testing-library/react` unmount, Radix DOM/style leaks, `window.location`) after every test.
20
22
  - `kumiko-testing bunfig [--dom] [--coverage]`: writes `bunfig.toml`,
21
23
  `bunfig.integration.toml` and `bunfig.real.toml` (plus `bunfig.dom.toml`). `--dom` adds
22
24
  `preload/dom` (happy-dom + testing-library/react cleanup); `@happy-dom/global-registrator` and
@@ -5,15 +5,20 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { parseArgs } from "node:util";
6
6
  import { Glob } from "bun";
7
7
  import { BUNFIG_FILES, mergeBunfig, renderBunfigFiles } from "../src/bunfig";
8
- import { buildIntegrationTestArgs, selectIntegrationFiles } from "../src/integration-runner";
8
+ import {
9
+ buildIntegrationTestArgs,
10
+ resolveRequestedIntegrationFiles,
11
+ selectIntegrationFiles,
12
+ } from "../src/integration-runner";
9
13
 
10
14
  const USAGE = `kumiko-testing <command>
11
15
 
12
16
  bunfig [--dom] [--coverage] [--hoisted] write bunfig.toml, bunfig.integration.toml and
13
17
  bunfig.real.toml (plus bunfig.dom.toml with --dom);
14
18
  --hoisted adds [install] linker = "hoisted"
15
- integration [--parallel N] run every *.integration.test.ts under the cwd
16
- [--timings <file>] [--update-timings]
19
+ integration [--parallel N] run every *.integration.test.ts under the cwd,
20
+ [--timings <file>] [--update-timings] or only the given file(s) when passed
21
+ [file...] as positional args
17
22
  `;
18
23
 
19
24
  function runBunfig(args: readonly string[]): number {
@@ -53,25 +58,36 @@ function runBunfig(args: readonly string[]): number {
53
58
  }
54
59
 
55
60
  async function runIntegration(args: readonly string[]): Promise<number> {
56
- const { values } = parseArgs({
61
+ const { values, positionals } = parseArgs({
57
62
  args: [...args],
58
63
  options: {
59
64
  parallel: { type: "string" },
60
65
  timings: { type: "string" },
61
66
  "update-timings": { type: "boolean" },
62
67
  },
68
+ allowPositionals: true,
63
69
  strict: true,
64
70
  });
65
71
  if (!existsSync(BUNFIG_FILES.integration)) {
66
72
  console.error(`${BUNFIG_FILES.integration} not found - run \`kumiko-testing bunfig\` first`);
67
73
  return 1;
68
74
  }
69
- const files = selectIntegrationFiles(
70
- await Array.fromAsync(new Glob("**/*.integration.test.ts").scan({ cwd: process.cwd() })),
71
- );
72
- if (files.length === 0) {
73
- console.error("no *.integration.test.ts files found");
74
- return 1;
75
+ let files: string[];
76
+ if (positionals.length > 0) {
77
+ try {
78
+ files = resolveRequestedIntegrationFiles(process.cwd(), positionals);
79
+ } catch (error) {
80
+ console.error(error instanceof Error ? error.message : String(error));
81
+ return 1;
82
+ }
83
+ } else {
84
+ files = selectIntegrationFiles(
85
+ await Array.fromAsync(new Glob("**/*.integration.test.ts").scan({ cwd: process.cwd() })),
86
+ );
87
+ if (files.length === 0) {
88
+ console.error("no *.integration.test.ts files found");
89
+ return 1;
90
+ }
75
91
  }
76
92
  const testArgs = buildIntegrationTestArgs({
77
93
  files,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.319.0",
3
+ "version": "0.321.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>",
@@ -42,6 +42,10 @@
42
42
  "types": "./src/preload/scrub-env.ts",
43
43
  "default": "./src/preload/scrub-env.ts"
44
44
  },
45
+ "./preload/schema-env-defaults": {
46
+ "types": "./src/preload/schema-env-defaults.ts",
47
+ "default": "./src/preload/schema-env-defaults.ts"
48
+ },
45
49
  "./preload/env": {
46
50
  "types": "./src/preload/env.ts",
47
51
  "default": "./src/preload/env.ts"
@@ -71,9 +75,9 @@
71
75
  "kumiko-testing": "./bin/kumiko-testing.ts"
72
76
  },
73
77
  "dependencies": {
74
- "@cosmicdrift/kumiko-bundled-features": "0.319.0",
75
- "@cosmicdrift/kumiko-dev-server": "0.319.0",
76
- "@cosmicdrift/kumiko-framework": "0.319.0",
78
+ "@cosmicdrift/kumiko-bundled-features": "0.321.0",
79
+ "@cosmicdrift/kumiko-dev-server": "0.321.0",
80
+ "@cosmicdrift/kumiko-framework": "0.321.0",
77
81
  "zod": "^4.4.3"
78
82
  },
79
83
  "peerDependencies": {
package/src/bunfig.ts CHANGED
@@ -28,7 +28,11 @@ const PRELOAD_PREFIX = "@cosmicdrift/kumiko-testing/preload";
28
28
  const DOM_PRELOAD = `${PRELOAD_PREFIX}/dom`;
29
29
 
30
30
  const PRELOADS = {
31
- unit: [`${PRELOAD_PREFIX}/temporal`, `${PRELOAD_PREFIX}/scrub-env`],
31
+ unit: [
32
+ `${PRELOAD_PREFIX}/temporal`,
33
+ `${PRELOAD_PREFIX}/scrub-env`,
34
+ `${PRELOAD_PREFIX}/schema-env-defaults`,
35
+ ],
32
36
  integration: [`${PRELOAD_PREFIX}/temporal`, `${PRELOAD_PREFIX}/env`],
33
37
  real: [`${PRELOAD_PREFIX}/temporal`, `${PRELOAD_PREFIX}/real`],
34
38
  } as const satisfies Record<BunfigVariant, readonly string[]>;
package/src/changes.json CHANGED
@@ -1,4 +1,37 @@
1
1
  [
2
+ {
3
+ "version": "0.321.0",
4
+ "type": "improvement",
5
+ "title": "kumiko-testing integration accepts positional test-file args",
6
+ "detail": "`kumiko-testing integration` now runs only the given files/globs when\npositional args are passed, instead of always discovering every\n`*.integration.test.ts` file.",
7
+ "migration": "No action needed — omitting positional args keeps the previous full-discovery behavior."
8
+ },
9
+ {
10
+ "version": "0.321.0",
11
+ "type": "improvement",
12
+ "title": "defineAppE2eConfig reserves KUMIKO_TRUSTED_PROXY_HOPS; loginViaApi sends a synthetic per-user X-Forwarded-For",
13
+ "detail": "`defineAppE2eConfig`'s `webServer.env` now sets\n`KUMIKO_TRUSTED_PROXY_HOPS=1` and rejects that key in a consumer's own\n`env` (template-owned, added to `RESERVED_ENV_KEYS`). `loginViaApi` sends\n`x-forwarded-for: syntheticClientIpFor(credentials.email)`, a new\nexported helper deriving a deterministic private-range IP from\n`sha256(email)`, so each seeded user's login lands in its own\nrate-limit bucket instead of every ::1 client sharing one.\n`loginViaUi` is unchanged.",
14
+ "migration": "Consumers using `defineAppE2eConfig` + `loginViaApi` (incl. the\n`seedTenant` fixture) need no changes — each seeded user now logs in\nfrom its own bucket. Browser logins (`loginViaUi`, raw `fetch` to\n`/api/auth/login` from a page) still share the `::1` bucket. A consumer\nwith its own e2e login helper (bypassing `loginViaApi`) should send\n`x-forwarded-for: syntheticClientIpFor(email)` (exported from\n`@cosmicdrift/kumiko-testing`) themselves, and must not set\n`KUMIKO_TRUSTED_PROXY_HOPS` in `defineAppE2eConfig`'s `env` — it's now\nreserved."
15
+ },
16
+ {
17
+ "version": "0.321.0",
18
+ "type": "fix",
19
+ "title": "e2e test fixture gives every test its own synthetic client IP",
20
+ "detail": "`test` from `@cosmicdrift/kumiko-testing/e2e` overrides the `context`\nfixture with a `context.route(\"**/api/**\")` that adds\n`x-forwarded-for: syntheticClientIpFor(perTestClientIpKey(testInfo))` to\nsame-origin requests; an explicit `x-forwarded-for` wins. Cross-origin\nrequests stay untouched because an extra header would force a CORS\npreflight. Routing disables the HTTP cache of the test's context.\n`seedTenant`'s per-user API contexts send\n`syntheticClientIpFor(user.email)`. The built-in `request` fixture is not\ncovered.",
21
+ "migration": "Specs that import `test` from `@cosmicdrift/kumiko-testing/e2e` need no\nchange. Specs importing `test` straight from `@playwright/test` keep\nsharing the `::1` bucket; switch their import to kumiko-testing."
22
+ },
23
+ {
24
+ "version": "0.320.0",
25
+ "type": "improvement",
26
+ "title": "requireRealProviders from kumiko-testing/e2e, SSE events for seedTenant tenants",
27
+ "detail": "`@cosmicdrift/kumiko-testing/e2e` re-exports `requireRealProviders`, `isRealProviderRun` and `REAL_PROVIDERS_ENV` from the framework's `testing/real-providers` subpath instead of keeping its own copy. Playwright specs and configs, which load under Node, no longer have to avoid the framework's `./testing` barrel.\n\n`seedTenant(stack, …)` subscribes `stack.events.sse` to the seeded tenant's SSE channel. Before, `setupTestStack` only listened on test tenant 1, so writes in a seeded tenant never showed up on `events.sse` and tests had to call `stack.sseBroker.addClient(tenantChannel(id), …)` themselves; that manual call can go. Events from `persist: true` seeding (tenant, user, membership) now also land on `events.sse` after the next drain."
28
+ },
29
+ {
30
+ "version": "0.320.0",
31
+ "type": "improvement",
32
+ "title": "Schema-required test env keys (JWT_SECRET, secrets master key) now default in every bunfig variant",
33
+ "detail": "Apps with the secrets, field-encryption or auth-mfa features previously had\nto hand-roll a `test-setup/env.preload.ts` to satisfy the env schema's\n`JWT_SECRET` and `KUMIKO_SECRETS_MASTER_KEY_*` in unit and DOM tests, since\nonly `SERVICE_ENV_DEFAULTS` (integration/real only) carried a `JWT_SECRET`\ndefault and no map carried the master key. The new `SCHEMA_ENV_DEFAULTS`\nmap lives next to `SERVICE_ENV_DEFAULTS` and is preloaded in all four bunfig\nvariants (unit, DOM, integration, real) via a new `preload/schema-env-defaults`\nmodule, filled with `??=` so an app's own value is kept; only a\n`JWT_SECRET` shorter than 32 characters is replaced.\n`SERVICE_ENV_DEFAULTS` no longer duplicates `JWT_SECRET` — it has one home\nnow. Unit and DOM still carry no service-endpoint defaults."
34
+ },
2
35
  {
3
36
  "version": "0.318.0",
4
37
  "type": "improvement",
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { currentTotpCode } from "@cosmicdrift/kumiko-bundled-features/auth-mfa/testing";
2
3
  import type { WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { type APIRequestContext, type APIResponse, expect, type Page } from "@playwright/test";
@@ -34,12 +35,23 @@ export async function csrfFetch(
34
35
  return request.post(path, { headers: csrfHeaderFromCookies(cookies), data });
35
36
  }
36
37
 
38
+ // Deterministic IP in the RFC 1918 private range, so the same key (a seeded
39
+ // user's email, a test's id) always lands in the same rate-limit bucket while
40
+ // different keys get distinct buckets (3 varying bytes ~= 16M slots).
41
+ export function syntheticClientIpFor(key: string): string {
42
+ const digest = createHash("sha256").update(key).digest();
43
+ return `10.${digest[0]}.${digest[1]}.${digest[2]}`;
44
+ }
45
+
46
+ export const CLIENT_IP_HEADER = "x-forwarded-for";
47
+
37
48
  export async function loginViaApi(
38
49
  request: APIRequestContext,
39
50
  credentials: LoginCredentials,
40
51
  ): Promise<void> {
41
52
  const response = await request.post("/api/auth/login", {
42
53
  data: { email: credentials.email, password: credentials.password },
54
+ headers: { [CLIENT_IP_HEADER]: syntheticClientIpFor(credentials.email) },
43
55
  });
44
56
  if (!response.ok()) {
45
57
  throw new Error(
@@ -15,18 +15,6 @@ export const SEED_TOKEN_ENV = "KUMIKO_TEST_SEED_TOKEN";
15
15
  export const SEED_TOKEN_HEADER = "x-kumiko-test-seed";
16
16
  export const E2E_WORKERS_ENV = "KUMIKO_E2E_WORKERS";
17
17
 
18
- // Same literal as the framework's requireRealProviders()/isRealProviderRun(),
19
- // duplicated because the Playwright config runs under Node and importing the
20
- // framework's "./testing" barrel there would pull in its full Bun-toolchain
21
- // dependency graph for a single string constant.
22
- export const REAL_PROVIDERS_ENV = "KUMIKO_REAL_PROVIDERS";
23
-
24
- export function isRealProviderRun(
25
- env: Readonly<Record<string, string | undefined>> = process.env,
26
- ): boolean {
27
- return env[REAL_PROVIDERS_ENV] === "1";
28
- }
29
-
30
18
  // Same literal as kumiko-dev-server's createKumikoServer (STYLESHEET_WATCH_ENV),
31
19
  // duplicated because the Playwright config runs under Node and can't import
32
20
  // that Bun-toolchain-adjacent module.
@@ -36,6 +24,10 @@ export const STYLESHEET_WATCH_ENV = "KUMIKO_DEV_STYLESHEET_WATCH";
36
24
  // duplicated for the same Node/Bun-toolchain reason.
37
25
  export const PROD_BUNDLES_ENV = "KUMIKO_DEV_PROD_BUNDLES";
38
26
 
27
+ // Same literal as kumiko-framework's client-ip.ts (TRUSTED_PROXY_HOPS_ENV),
28
+ // duplicated for the same Node/Bun-toolchain reason.
29
+ export const TRUSTED_PROXY_HOPS_ENV = "KUMIKO_TRUSTED_PROXY_HOPS";
30
+
39
31
  export const SEED_ROUTE_PREFIX = "/__test";
40
32
 
41
33
  export const SEED_ROUTES = {
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { availableParallelism } from "node:os";
3
+ import { isRealProviderRun } from "@cosmicdrift/kumiko-framework/testing/real-providers";
3
4
  import {
4
5
  defineConfig,
5
6
  devices,
@@ -12,13 +13,13 @@ import { SERVICE_ENV_DEFAULTS } from "../preload/service-env-defaults-values";
12
13
  import {
13
14
  DESKTOP_VIEWPORT,
14
15
  E2E_WORKERS_ENV,
15
- isRealProviderRun,
16
16
  PLAYWRIGHT_DEMO_ENV,
17
17
  PROD_BUNDLES_ENV,
18
18
  SCREENSHOT_DEVICE_SCALE_FACTOR,
19
19
  SEED_ENABLE_ENV,
20
20
  SEED_TOKEN_ENV,
21
21
  STYLESHEET_WATCH_ENV,
22
+ TRUSTED_PROXY_HOPS_ENV,
22
23
  } from "./constants";
23
24
  import { isScreenshotRun, requireScreenshotDir, screenshotSpecsIgnore } from "./screenshot-dir";
24
25
  import { E2E_TIMEOUT_MS } from "./timeouts";
@@ -61,6 +62,7 @@ const RESERVED_ENV_KEYS = [
61
62
  SEED_TOKEN_ENV,
62
63
  STYLESHEET_WATCH_ENV,
63
64
  PROD_BUNDLES_ENV,
65
+ TRUSTED_PROXY_HOPS_ENV,
64
66
  ] as const;
65
67
 
66
68
  type ProjectUse = Partial<PlaywrightTestOptions & PlaywrightWorkerOptions>;
@@ -205,6 +207,11 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
205
207
  // E2E runs against prod-shaped bundles: splitting, no sourcemap,
206
208
  // NODE_ENV=production — matches what actually ships.
207
209
  [PROD_BUNDLES_ENV]: "1",
210
+ // E2e clients all share ::1, so the default trustedProxyHops (0,
211
+ // socket-only) would collapse every seeded user into one rate-limit
212
+ // bucket. loginViaApi sends a synthetic per-user X-Forwarded-For, so
213
+ // trust exactly one hop to give each user its own bucket.
214
+ [TRUSTED_PROXY_HOPS_ENV]: "1",
208
215
  },
209
216
  reuseExistingServer: false,
210
217
  timeout: E2E_TIMEOUT_MS.webServer,
package/src/e2e/index.ts CHANGED
@@ -1,3 +1,8 @@
1
+ export {
2
+ isRealProviderRun,
3
+ REAL_PROVIDERS_ENV,
4
+ requireRealProviders,
5
+ } from "@cosmicdrift/kumiko-framework/testing/real-providers";
1
6
  export { expect } from "@playwright/test";
2
7
  export {
3
8
  apiCommand,
@@ -10,16 +15,15 @@ export {
10
15
  type LoginCredentials,
11
16
  loginViaApi,
12
17
  loginViaUi,
18
+ syntheticClientIpFor,
13
19
  totpCode,
14
20
  } from "./auth-kit";
15
21
  export {
16
22
  CSRF_COOKIE_NAME,
17
23
  CSRF_HEADER_NAME,
18
24
  E2E_WORKERS_ENV,
19
- isRealProviderRun,
20
25
  KUMIKO_SECRETS_MASTER_KEY_V1,
21
26
  PLAYWRIGHT_DEMO_ENV,
22
- REAL_PROVIDERS_ENV,
23
27
  SEED_ENABLE_ENV,
24
28
  SEED_TOKEN_ENV,
25
29
  } from "./constants";
@@ -1,8 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { mkdirSync, statSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
+ import { isRealProviderRun } from "@cosmicdrift/kumiko-framework/testing/real-providers";
4
5
  import { expect, type Page, type Request } from "@playwright/test";
5
- import { DESKTOP_VIEWPORT, isRealProviderRun } from "./constants";
6
+ import { DESKTOP_VIEWPORT } from "./constants";
6
7
  import { pinEnglishLocale } from "./pin-english-locale";
7
8
  import { requireScreenshotDir, SCREENSHOT_DIR_ENV } from "./screenshot-dir";
8
9
  import { type SeedTenantFixture, test } from "./seeded-tenant-fixture";
@@ -1,11 +1,14 @@
1
1
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
2
2
  import {
3
3
  type APIRequestContext,
4
+ type BrowserContext,
4
5
  test as base,
5
6
  type Page,
7
+ type Request as PlaywrightRequest,
6
8
  type PlaywrightTestArgs,
7
9
  type PlaywrightTestOptions,
8
10
  type PlaywrightWorkerArgs,
11
+ type TestInfo,
9
12
  } from "@playwright/test";
10
13
  import type * as z from "zod";
11
14
  import {
@@ -16,8 +19,14 @@ import {
16
19
  type SeedTenantOptions,
17
20
  withSession,
18
21
  } from "../seed-types";
19
- import { clearSession, createHttpApi, loginViaApi } from "./auth-kit";
20
- import { SEED_ROUTES } from "./constants";
22
+ import {
23
+ CLIENT_IP_HEADER,
24
+ clearSession,
25
+ createHttpApi,
26
+ loginViaApi,
27
+ syntheticClientIpFor,
28
+ } from "./auth-kit";
29
+ import { SEED_ROUTES, SEED_TOKEN_ENV } from "./constants";
21
30
  import { seedRouteHeaders } from "./mail-capture";
22
31
  import {
23
32
  type ExtraSeedRequest,
@@ -77,7 +86,10 @@ export async function provideSeedTenant(
77
86
 
78
87
  // One cookie jar per user: two tenants (or admin + member) in one test must not clobber each other's session.
79
88
  const openLoggedInApi = async (user: SeededUser): Promise<BoundApi> => {
80
- const ctx = await playwright.request.newContext({ baseURL });
89
+ const ctx = await playwright.request.newContext({
90
+ baseURL,
91
+ extraHTTPHeaders: { [CLIENT_IP_HEADER]: syntheticClientIpFor(user.email) },
92
+ });
81
93
  openedContexts.push(ctx);
82
94
  await loginViaApi(ctx, user);
83
95
  return createHttpApi(ctx);
@@ -166,6 +178,65 @@ export async function provideSeedTenant(
166
178
  await Promise.all(openedContexts.map((ctx) => ctx.dispose()));
167
179
  }
168
180
 
181
+ // Every e2e client connects from ::1, and the e2e webServer trusts one proxy
182
+ // hop (defineAppE2eConfig). Without a distinct X-Forwarded-For per test, all
183
+ // browser pages of a run would share one `per: "ip"` rate-limit bucket and
184
+ // 429 under parallel load. The run token keeps a rerun within the limiter
185
+ // window out of the previous run's buckets; the retry index keeps a retry
186
+ // out of the bucket its failed attempt already drained.
187
+ export function perTestClientIpKey(
188
+ testInfo: Pick<TestInfo, "testId" | "repeatEachIndex" | "retry">,
189
+ runToken: string | undefined = process.env[SEED_TOKEN_ENV],
190
+ ): string {
191
+ return `${runToken ?? ""}:${testInfo.testId}:${testInfo.repeatEachIndex}:${testInfo.retry}`;
192
+ }
193
+
194
+ // Same-origin only: on a cross-origin fetch the extra header would force a
195
+ // CORS preflight the target never agreed to (why extraHTTPHeaders is no option
196
+ // here). An explicit X-Forwarded-For set by the test wins.
197
+ export function headersWithClientIp(
198
+ request: { readonly url: string; readonly frameUrl: string | undefined },
199
+ headers: Readonly<Record<string, string>>,
200
+ clientIp: string,
201
+ ): Record<string, string> | undefined {
202
+ if (request.frameUrl === undefined) return undefined;
203
+ let sameOrigin: boolean;
204
+ try {
205
+ sameOrigin = new URL(request.frameUrl).origin === new URL(request.url).origin;
206
+ } catch {
207
+ return undefined;
208
+ }
209
+ return sameOrigin ? { [CLIENT_IP_HEADER]: clientIp, ...headers } : undefined;
210
+ }
211
+
212
+ function frameUrlOf(request: PlaywrightRequest): string | undefined {
213
+ try {
214
+ return request.frame().url();
215
+ } catch {
216
+ // Service-worker requests have no frame.
217
+ return undefined;
218
+ }
219
+ }
220
+
221
+ export async function providePerTestClientIpContext(
222
+ { context }: Pick<PlaywrightTestArgs, "context">,
223
+ use: (context: BrowserContext) => Promise<void>,
224
+ testInfo: Pick<TestInfo, "testId" | "repeatEachIndex" | "retry">,
225
+ ): Promise<void> {
226
+ const clientIp = syntheticClientIpFor(perTestClientIpKey(testInfo));
227
+ await context.route("**/api/**", (route) => {
228
+ const request = route.request();
229
+ const headers = headersWithClientIp(
230
+ { url: request.url(), frameUrl: frameUrlOf(request) },
231
+ request.headers(),
232
+ clientIp,
233
+ );
234
+ return headers === undefined ? route.fallback() : route.fallback({ headers });
235
+ });
236
+ await use(context);
237
+ }
238
+
169
239
  export const test = base.extend<E2eFixtures>({
240
+ context: providePerTestClientIpContext,
170
241
  seedTenant: provideSeedTenant,
171
242
  });
@@ -1,3 +1,5 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
1
3
  import { BUNFIG_FILES, TEST_TIMEOUT_MS } from "./bunfig";
2
4
 
3
5
  export type IntegrationRunOptions = {
@@ -16,6 +18,26 @@ export function selectIntegrationFiles(paths: readonly string[]): string[] {
16
18
  .sort();
17
19
  }
18
20
 
21
+ /** Resolves `kumiko-testing integration`'s positional file args against `cwd`
22
+ * into absolute paths — absolute (not glob-relative) so bun's own
23
+ * `bun test <path>` treats them as files to run, not filter patterns.
24
+ * `exists` is injectable so this stays unit-testable without touching the
25
+ * real filesystem. Throws on the first missing file — the caller decides
26
+ * how to report it (CLI: print + exit 1). */
27
+ export function resolveRequestedIntegrationFiles(
28
+ cwd: string,
29
+ positionals: readonly string[],
30
+ exists: (path: string) => boolean = existsSync,
31
+ ): string[] {
32
+ return positionals.map((arg) => {
33
+ const resolved = resolve(cwd, arg);
34
+ if (!exists(resolved)) {
35
+ throw new Error(`kumiko-testing integration: file not found: ${arg}`);
36
+ }
37
+ return resolved;
38
+ });
39
+ }
40
+
19
41
  const isPositiveInteger = (value: number): boolean => Number.isInteger(value) && value > 0;
20
42
 
21
43
  export function buildIntegrationTestArgs(opts: IntegrationRunOptions): string[] {
@@ -1,2 +1,3 @@
1
1
  import "./scrub-env";
2
+ import "./schema-env-defaults";
2
3
  import "./service-env-defaults";
@@ -1,4 +1,5 @@
1
1
  import { requireRealProviders } from "@cosmicdrift/kumiko-framework/testing";
2
+ import "./schema-env-defaults";
2
3
  import "./service-env-defaults";
3
4
 
4
5
  requireRealProviders();
@@ -0,0 +1,6 @@
1
+ // KUMIKO_SECRETS_MASTER_KEY_V1 must base64-decode to exactly 32 bytes (AES-256 KEK).
2
+ export const SCHEMA_ENV_DEFAULTS = {
3
+ JWT_SECRET: "test-jwt-secret-at-least-32-characters-long",
4
+ KUMIKO_SECRETS_MASTER_KEY_V1: "a3VtaWtvLXNjaGVtYS1lbnYtZGVmYXVsdC0zMmJ5dGU=",
5
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
6
+ } as const satisfies Record<string, string>;
@@ -0,0 +1,18 @@
1
+ import { SCHEMA_ENV_DEFAULTS } from "./schema-env-defaults-values";
2
+
3
+ // Mirrors authEmailPasswordEnvSchema's JWT_SECRET.min(32) / jwt.ts's HS256
4
+ // floor: a too-short value from a stray .env is replaced, not kept, same as
5
+ // the app-local preloads this centralizes did before it.
6
+ const MIN_JWT_SECRET_LENGTH = 32;
7
+
8
+ function isTooShortJwtSecret(key: string, value: string | undefined): boolean {
9
+ return key === "JWT_SECRET" && (!value || value.length < MIN_JWT_SECRET_LENGTH);
10
+ }
11
+
12
+ for (const [key, value] of Object.entries(SCHEMA_ENV_DEFAULTS)) {
13
+ if (isTooShortJwtSecret(key, process.env[key])) {
14
+ process.env[key] = value;
15
+ continue;
16
+ }
17
+ process.env[key] ??= value;
18
+ }
@@ -4,7 +4,6 @@ export const SERVICE_ENV_DEFAULTS = {
4
4
  REDIS_URL: "redis://localhost:16379",
5
5
  MEILI_URL: "http://localhost:17700",
6
6
  MEILI_MASTER_KEY: "kumiko-dev-key",
7
- JWT_SECRET: "test-jwt-secret-at-least-32-characters-long",
8
7
  MINIO_ENDPOINT: "http://localhost:19000",
9
8
  MINIO_ACCESS_KEY: "kumiko",
10
9
  MINIO_SECRET_KEY: "kumiko-dev-secret",
package/src/scaffold.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // @runtime tooling
2
+ import { REAL_PROVIDERS_ENV } from "@cosmicdrift/kumiko-framework/testing/real-providers";
2
3
  import { BUNFIG_FILES, renderBunfigFiles, TEST_TIMEOUT_MS } from "./bunfig";
3
- import { REAL_PROVIDERS_ENV } from "./e2e/constants";
4
4
 
5
5
  export type ScaffoldTestSetup = {
6
6
  readonly files: Readonly<Record<string, string>>;
@@ -7,6 +7,7 @@ import {
7
7
  createSystemUser,
8
8
  type SessionUser,
9
9
  type TenantId,
10
+ tenantChannel,
10
11
  type WriteResult,
11
12
  } from "@cosmicdrift/kumiko-framework/engine";
12
13
  import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
@@ -177,6 +178,13 @@ export async function seedTenant(
177
178
  const rows = persist ? await persistTenantRows(write, opts) : lightTenantRows(opts);
178
179
  const { id } = rows;
179
180
 
181
+ // setupTestStack subscribes events.sse only to test tenant 1; a seeded tenant has a fresh random id.
182
+ stack.sseBroker.addClient(
183
+ tenantChannel(id),
184
+ (event) => stack.events.sse.push(event),
185
+ () => {},
186
+ );
187
+
180
188
  const admin = withSession(rows.admin, id, [ROLES.TenantAdmin]);
181
189
  const tenant: SeededTenant = {
182
190
  id,