@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 +8 -6
- package/bin/kumiko-testing.ts +26 -10
- package/package.json +8 -4
- package/src/bunfig.ts +5 -1
- package/src/changes.json +33 -0
- package/src/e2e/auth-kit.ts +12 -0
- package/src/e2e/constants.ts +4 -12
- package/src/e2e/define-app-e2e-config.ts +8 -1
- package/src/e2e/index.ts +6 -2
- package/src/e2e/screenshots.ts +2 -1
- package/src/e2e/seeded-tenant-fixture.ts +74 -3
- package/src/integration-runner.ts +22 -0
- package/src/preload/env.ts +1 -0
- package/src/preload/real.ts +1 -0
- package/src/preload/schema-env-defaults-values.ts +6 -0
- package/src/preload/schema-env-defaults.ts +18 -0
- package/src/preload/service-env-defaults-values.ts +0 -1
- package/src/scaffold.ts +1 -1
- package/src/seed-tenant.ts +8 -0
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.
|
|
15
|
-
`PROVIDER_ENV_KEYS` (unit default); `env`
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
`
|
|
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
|
package/bin/kumiko-testing.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
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.
|
|
75
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
76
|
-
"@cosmicdrift/kumiko-framework": "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: [
|
|
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",
|
package/src/e2e/auth-kit.ts
CHANGED
|
@@ -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(
|
package/src/e2e/constants.ts
CHANGED
|
@@ -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";
|
package/src/e2e/screenshots.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
20
|
-
|
|
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({
|
|
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[] {
|
package/src/preload/env.ts
CHANGED
package/src/preload/real.ts
CHANGED
|
@@ -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>>;
|
package/src/seed-tenant.ts
CHANGED
|
@@ -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,
|