@cosmicdrift/kumiko-testing 0.383.1 → 0.384.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.383.1",
3
+ "version": "0.384.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>",
@@ -99,9 +99,9 @@
99
99
  "kumiko-testing": "./bin/kumiko-testing.ts"
100
100
  },
101
101
  "dependencies": {
102
- "@cosmicdrift/kumiko-framework": "0.383.1",
103
- "@cosmicdrift/kumiko-guards": "0.383.1",
104
- "@cosmicdrift/kumiko-types": "0.383.1",
102
+ "@cosmicdrift/kumiko-framework": "0.384.0",
103
+ "@cosmicdrift/kumiko-guards": "0.384.0",
104
+ "@cosmicdrift/kumiko-types": "0.384.0",
105
105
  "zod": "^4.4.3"
106
106
  },
107
107
  "peerDependencies": {
@@ -125,8 +125,8 @@
125
125
  }
126
126
  },
127
127
  "devDependencies": {
128
- "@cosmicdrift/kumiko-bundled-features": "0.383.1",
129
- "@cosmicdrift/kumiko-dev-server": "0.383.1",
128
+ "@cosmicdrift/kumiko-bundled-features": "0.384.0",
129
+ "@cosmicdrift/kumiko-dev-server": "0.384.0",
130
130
  "@happy-dom/global-registrator": "^20.11.2",
131
131
  "@playwright/test": "^1.62.1",
132
132
  "@testing-library/dom": "^10.4.1",
@@ -1,4 +1,7 @@
1
- import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type { AccessRule, SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { createTestUser } from "@cosmicdrift/kumiko-framework/seeding";
4
+ import type { TestStack } from "../stack/test-stack.ts";
2
5
 
3
6
  // Test-only helper: extracts the role list from a role-based AccessRule,
4
7
  // narrowing the union safely. Throws when the rule is openToAll or missing —
@@ -13,3 +16,69 @@ export function rolesOf(access: AccessRule | undefined): readonly string[] {
13
16
  }
14
17
  return access.roles;
15
18
  }
19
+
20
+ const PROBE_ROLE = "access-contract-probe";
21
+
22
+ type CheckedHandlerCounts = { checkedWriteHandlers: number; checkedQueryHandlers: number };
23
+
24
+ function isRoleRestricted(access: AccessRule | undefined): boolean {
25
+ return access !== undefined && "roles" in access && access.roles.length > 0;
26
+ }
27
+
28
+ async function deniedOutcome(res: Response): Promise<string | undefined> {
29
+ const rawBody = await res.json().catch(() => undefined);
30
+ const body = rawBody as { error?: { code?: unknown } | string } | undefined; // @cast-boundary engine-bridge
31
+ const code =
32
+ typeof body?.error === "object" && typeof body.error.code === "string"
33
+ ? body.error.code
34
+ : undefined;
35
+ if (res.status === 403 && code === ACCESS_DENIED_CODE) return undefined;
36
+ return `HTTP ${res.status} ${code ?? "ok"}`;
37
+ }
38
+
39
+ const ACCESS_DENIED_CODE = new AccessDeniedError().code;
40
+
41
+ /**
42
+ * Contract test: every role-restricted write/query handler in the stack rejects
43
+ * a signed-in caller who holds none of its roles with 403 access_denied, over
44
+ * real HTTP. Roles are checked centrally before payload parsing (pre-handler
45
+ * gates / dispatch-query), so an empty payload is enough. openToAll handlers
46
+ * and handlers without access are skipped. Throws once with all deviations;
47
+ * `minimumHandlerCount` guards against an empty registry walk passing silently.
48
+ */
49
+ export async function expectRoleRestrictedHandlersDenyForeignRole(
50
+ stack: Pick<TestStack, "registry" | "http">,
51
+ options: { minimumHandlerCount?: number; probeUser?: SessionUser } = {},
52
+ ): Promise<CheckedHandlerCounts> {
53
+ const probe = options.probeUser ?? createTestUser({ roles: [PROBE_ROLE] });
54
+ const minimumHandlerCount = options.minimumHandlerCount ?? 1;
55
+ const deviations: string[] = [];
56
+ let checkedWriteHandlers = 0;
57
+ let checkedQueryHandlers = 0;
58
+
59
+ for (const [handlerName, handler] of stack.registry.getAllWriteHandlers()) {
60
+ if (!isRoleRestricted(handler.access)) continue;
61
+ checkedWriteHandlers++;
62
+ const outcome = await deniedOutcome(await stack.http.write(handlerName, {}, probe));
63
+ if (outcome) deviations.push(`write ${handlerName}: ${outcome}`);
64
+ }
65
+ for (const [handlerName, handler] of stack.registry.getAllQueryHandlers()) {
66
+ if (!isRoleRestricted(handler.access)) continue;
67
+ checkedQueryHandlers++;
68
+ const outcome = await deniedOutcome(await stack.http.query(handlerName, {}, probe));
69
+ if (outcome) deviations.push(`query ${handlerName}: ${outcome}`);
70
+ }
71
+
72
+ const checked = checkedWriteHandlers + checkedQueryHandlers;
73
+ if (checked < minimumHandlerCount) {
74
+ throw new Error(
75
+ `expected at least ${minimumHandlerCount} role-restricted handlers, checked ${checked}`,
76
+ );
77
+ }
78
+ if (deviations.length > 0) {
79
+ throw new Error(
80
+ `${deviations.length} role-restricted handler(s) did not answer 403 ${ACCESS_DENIED_CODE}:\n${deviations.join("\n")}`,
81
+ );
82
+ }
83
+ return { checkedWriteHandlers, checkedQueryHandlers };
84
+ }
@@ -103,8 +103,14 @@ function stubTranslations(feature: FeatureDefinition): FeatureDefinition {
103
103
  Object.entries(feature.translations ?? {}).map(([key, value]) => [key, { ...value }]),
104
104
  ),
105
105
  };
106
+ // Stub only the locales the feature already maintains, otherwise an en-only
107
+ // feature would become locale-inconsistent and trip the boot check.
108
+ const existingLocales = new Set(Object.values(keys).flatMap((entry) => Object.keys(entry)));
109
+ const stubLocales = existingLocales.size > 0 ? [...existingLocales] : ["de", "en"];
106
110
  for (const key of requiredKeysFromFeature(feature)) {
107
- if (keys[key] === undefined) keys[key] = { de: "stub", en: "stub" };
111
+ if (keys[key] === undefined) {
112
+ keys[key] = Object.fromEntries(stubLocales.map((locale) => [locale, "stub"]));
113
+ }
108
114
  }
109
115
  return { ...feature, translations: keys };
110
116
  }
@@ -13,7 +13,7 @@ export {
13
13
  resetEventPiiCatalogForTests,
14
14
  resetPiiSubjectKmsForTests,
15
15
  } from "@cosmicdrift/kumiko-framework/internal/testing";
16
- export { rolesOf } from "./access-assertions.ts";
16
+ export { expectRoleRestrictedHandlersDenyForeignRole, rolesOf } from "./access-assertions.ts";
17
17
  export { expectError, expectSuccess } from "./assertions.ts";
18
18
  export { type Barrier, createBarrier } from "./barrier.ts";
19
19
  export { withBootValidatorFixture } from "./boot-validator-fixture.ts";
@@ -87,7 +87,7 @@ if (typeof globalThis.Element !== "undefined") {
87
87
  // test files in one process, so without afterEach, React components from
88
88
  // file N stay mounted in file N+1's DOM.
89
89
  //
90
- // FIVE leak sources (b is NOT cleaned here):
90
+ // SIX leak sources (b is NOT cleaned here):
91
91
  //
92
92
  // a) testing-library/react container: cleanup() unmounts and removes
93
93
  // every container node render() created.
@@ -103,6 +103,8 @@ if (typeof globalThis.Element !== "undefined") {
103
103
  // d) Radix-injected style tags in head.
104
104
  //
105
105
  // e) window.location / history.pushState.
106
+ //
107
+ // f) document.cookie.
106
108
  afterEach(() => {
107
109
  // skip: this preload module runs its own afterEach unconditionally; plain
108
110
  // unit test files (no --dom) never get happy-dom registered, so document
@@ -135,6 +137,18 @@ afterEach(() => {
135
137
  }
136
138
  }
137
139
 
140
+ // (f) Cookies outlive the test file (e.g. an auth test's kumiko_csrf turns
141
+ // up in a later CSRF-header assertion). happy-dom deletes a cookie only
142
+ // for its exact stored path, which is '' when it was set without path.
143
+ for (const cookie of doc.cookie.split(";")) {
144
+ const name = cookie.split("=")[0]?.trim();
145
+ if (!name) continue;
146
+ for (const pathAttribute of ["", "; path=/"]) {
147
+ // biome-ignore lint/suspicious/noDocumentCookie: happy-dom has no Cookie Store API
148
+ doc.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT${pathAttribute}`;
149
+ }
150
+ }
151
+
138
152
  // (e) Reset window.location to happy-dom's initial url. replaceState
139
153
  // instead of pushState, otherwise a history stack of 3000+ entries
140
154
  // accumulates across all tests (memory leak, plus it breaks tests that