@cosmicdrift/kumiko-testing 0.320.0 → 0.322.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.
@@ -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.320.0",
3
+ "version": "0.322.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>",
@@ -75,9 +75,9 @@
75
75
  "kumiko-testing": "./bin/kumiko-testing.ts"
76
76
  },
77
77
  "dependencies": {
78
- "@cosmicdrift/kumiko-bundled-features": "0.320.0",
79
- "@cosmicdrift/kumiko-dev-server": "0.320.0",
80
- "@cosmicdrift/kumiko-framework": "0.320.0",
78
+ "@cosmicdrift/kumiko-bundled-features": "0.322.0",
79
+ "@cosmicdrift/kumiko-dev-server": "0.322.0",
80
+ "@cosmicdrift/kumiko-framework": "0.322.0",
81
81
  "zod": "^4.4.3"
82
82
  },
83
83
  "peerDependencies": {
package/src/changes.json CHANGED
@@ -1,4 +1,25 @@
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
+ },
2
23
  {
3
24
  "version": "0.320.0",
4
25
  "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(
@@ -24,6 +24,10 @@ export const STYLESHEET_WATCH_ENV = "KUMIKO_DEV_STYLESHEET_WATCH";
24
24
  // duplicated for the same Node/Bun-toolchain reason.
25
25
  export const PROD_BUNDLES_ENV = "KUMIKO_DEV_PROD_BUNDLES";
26
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
+
27
31
  export const SEED_ROUTE_PREFIX = "/__test";
28
32
 
29
33
  export const SEED_ROUTES = {
@@ -19,6 +19,7 @@ import {
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
@@ -15,6 +15,7 @@ export {
15
15
  type LoginCredentials,
16
16
  loginViaApi,
17
17
  loginViaUi,
18
+ syntheticClientIpFor,
18
19
  totpCode,
19
20
  } from "./auth-kit";
20
21
  export {
@@ -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[] {