@cosmicdrift/kumiko-testing 0.383.1 → 0.385.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.
|
|
3
|
+
"version": "0.385.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.
|
|
103
|
-
"@cosmicdrift/kumiko-guards": "0.
|
|
104
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
102
|
+
"@cosmicdrift/kumiko-framework": "0.385.0",
|
|
103
|
+
"@cosmicdrift/kumiko-guards": "0.385.0",
|
|
104
|
+
"@cosmicdrift/kumiko-types": "0.385.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.
|
|
129
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
128
|
+
"@cosmicdrift/kumiko-bundled-features": "0.385.0",
|
|
129
|
+
"@cosmicdrift/kumiko-dev-server": "0.385.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",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.385.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Tests use waitFor and sleep helpers instead of inline timers",
|
|
6
|
+
"detail": "Fixed waits that back a TTL bound, an absence or an ordering gap use sleep with a reason; the combobox debounce test polls with waitFor."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.382.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -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)
|
|
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
|
}
|
package/src/helpers/index.ts
CHANGED
|
@@ -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";
|
package/src/preload/dom.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|