@cosmicdrift/kumiko-framework 0.153.0 → 0.154.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 +2 -2
- package/src/__tests__/anonymous-access.integration.test.ts +25 -0
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
- package/src/engine/__tests__/boot-validator.test.ts +7 -4
- package/src/engine/boot-validator/action-wiring.ts +99 -0
- package/src/engine/boot-validator/index.ts +7 -0
- package/src/stack/redis.ts +8 -0
- package/src/stack/test-stack.ts +156 -136
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.154.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"zod": "^4.4.3"
|
|
194
194
|
},
|
|
195
195
|
"devDependencies": {
|
|
196
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
196
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.154.0",
|
|
197
197
|
"bun-types": "^1.3.13",
|
|
198
198
|
"pino-pretty": "^13.1.3"
|
|
199
199
|
},
|
|
@@ -361,6 +361,31 @@ describe("anonymous access — resolverTrust: authoritative", () => {
|
|
|
361
361
|
const body = (await res.json()) as { error: { code: string } };
|
|
362
362
|
expect(body.error.code).toBe("tenant_mismatch");
|
|
363
363
|
});
|
|
364
|
+
|
|
365
|
+
// pr-review kumiko-framework #1052/3 — resolveTenant treats header and
|
|
366
|
+
// cookie identically, but every case above only exercised the header.
|
|
367
|
+
// These two lock in that the cookie path behaves the same way.
|
|
368
|
+
test("kumiko_tenant cookie agreeing with the resolver → accepted", async () => {
|
|
369
|
+
const res = await stack.http.raw(
|
|
370
|
+
"POST",
|
|
371
|
+
"/api/query",
|
|
372
|
+
{ type: "anonshop:query:product:list", payload: {} },
|
|
373
|
+
{ Cookie: `kumiko_tenant=${TENANT_ID}` },
|
|
374
|
+
);
|
|
375
|
+
expect(res.status).toBe(200);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("kumiko_tenant cookie disagreeing with the resolver → 400 tenant_mismatch, same as a disagreeing header", async () => {
|
|
379
|
+
const res = await stack.http.raw(
|
|
380
|
+
"POST",
|
|
381
|
+
"/api/query",
|
|
382
|
+
{ type: "anonshop:query:product:list", payload: {} },
|
|
383
|
+
{ Cookie: `kumiko_tenant=${OTHER_TENANT_ID}` },
|
|
384
|
+
);
|
|
385
|
+
expect(res.status).toBe(400);
|
|
386
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
387
|
+
expect(body.error.code).toBe("tenant_mismatch");
|
|
388
|
+
});
|
|
364
389
|
});
|
|
365
390
|
|
|
366
391
|
describe("anonymous access — resolverTrust: authoritative, resolver returns null", () => {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { validateBoot } from "../boot-validator";
|
|
4
|
+
import { defineFeature } from "../define-feature";
|
|
5
|
+
import { createEntity, createTextField } from "../factories";
|
|
6
|
+
|
|
7
|
+
describe("validateBoot — action wiring (no function values)", () => {
|
|
8
|
+
test("rowAction writeHandler payload as function → Throw", () => {
|
|
9
|
+
const feature = defineFeature("shop", (r) => {
|
|
10
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
11
|
+
r.screen({
|
|
12
|
+
id: "product-list",
|
|
13
|
+
type: "entityList",
|
|
14
|
+
entity: "product",
|
|
15
|
+
columns: ["name"],
|
|
16
|
+
rowActions: [
|
|
17
|
+
{
|
|
18
|
+
kind: "writeHandler",
|
|
19
|
+
id: "sync",
|
|
20
|
+
label: "actions.sync",
|
|
21
|
+
handler: "shop:write:sync",
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
23
|
+
payload: ((row: unknown) => ({ id: row })) as any,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
28
|
+
access: { roles: ["Admin"] },
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
expect(() => validateBoot([feature])).toThrow(/rowAction "sync" payload is a function/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("rowAction writeHandler payload as declarative pick → kein Throw", () => {
|
|
35
|
+
const feature = defineFeature("shop", (r) => {
|
|
36
|
+
r.entity("product", createEntity({ fields: { name: createTextField({ sortable: true }) } }));
|
|
37
|
+
r.screen({
|
|
38
|
+
id: "product-list",
|
|
39
|
+
type: "entityList",
|
|
40
|
+
entity: "product",
|
|
41
|
+
columns: ["name"],
|
|
42
|
+
defaultSort: { field: "name", dir: "asc" },
|
|
43
|
+
rowActions: [
|
|
44
|
+
{
|
|
45
|
+
kind: "writeHandler",
|
|
46
|
+
id: "sync",
|
|
47
|
+
label: "actions.sync",
|
|
48
|
+
handler: "shop:write:sync",
|
|
49
|
+
payload: { pick: ["name"] },
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
54
|
+
access: { roles: ["Admin"] },
|
|
55
|
+
});
|
|
56
|
+
r.translations({
|
|
57
|
+
keys: {
|
|
58
|
+
"screen:product-list.title": { de: "Liste", en: "List" },
|
|
59
|
+
"shop:entity:product:field:name": { de: "Name", en: "Name" },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("rowAction navigate visible as function → Throw", () => {
|
|
67
|
+
const feature = defineFeature("shop", (r) => {
|
|
68
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
69
|
+
r.screen({
|
|
70
|
+
id: "product-list",
|
|
71
|
+
type: "entityList",
|
|
72
|
+
entity: "product",
|
|
73
|
+
columns: ["name"],
|
|
74
|
+
rowActions: [
|
|
75
|
+
{
|
|
76
|
+
kind: "navigate",
|
|
77
|
+
id: "edit",
|
|
78
|
+
label: "actions.edit",
|
|
79
|
+
screen: "product-list",
|
|
80
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
81
|
+
visible: (() => true) as any,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
expect(() => validateBoot([feature])).toThrow(/rowAction "edit" visible is a function/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("toolbarAction writeHandler payload as function → Throw", () => {
|
|
90
|
+
const feature = defineFeature("shop", (r) => {
|
|
91
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
92
|
+
r.screen({
|
|
93
|
+
id: "product-list",
|
|
94
|
+
type: "entityList",
|
|
95
|
+
entity: "product",
|
|
96
|
+
columns: ["name"],
|
|
97
|
+
toolbarActions: [
|
|
98
|
+
{
|
|
99
|
+
kind: "writeHandler",
|
|
100
|
+
id: "sync",
|
|
101
|
+
label: "actions.sync",
|
|
102
|
+
handler: "shop:write:sync",
|
|
103
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
104
|
+
payload: (() => ({})) as any,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
109
|
+
access: { roles: ["Admin"] },
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
expect(() => validateBoot([feature])).toThrow(/toolbarAction "sync" payload is a function/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("entityList column renderer as function → Throw", () => {
|
|
116
|
+
const feature = defineFeature("shop", (r) => {
|
|
117
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
118
|
+
r.screen({
|
|
119
|
+
id: "product-list",
|
|
120
|
+
type: "entityList",
|
|
121
|
+
entity: "product",
|
|
122
|
+
columns: [
|
|
123
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
124
|
+
{ field: "name", renderer: ((v: unknown) => String(v)) as any },
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
expect(() => validateBoot([feature])).toThrow(/column "name" renderer is a function/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("entityEdit field visible as function → Throw", () => {
|
|
132
|
+
const feature = defineFeature("shop", (r) => {
|
|
133
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
134
|
+
r.screen({
|
|
135
|
+
id: "product-edit",
|
|
136
|
+
type: "entityEdit",
|
|
137
|
+
entity: "product",
|
|
138
|
+
layout: {
|
|
139
|
+
sections: [
|
|
140
|
+
{
|
|
141
|
+
columns: 1,
|
|
142
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
143
|
+
fields: [{ field: "name", visible: (() => true) as any }],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
expect(() => validateBoot([feature])).toThrow(/field "name" visible is a function/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("entityEdit field renderer as function → Throw", () => {
|
|
153
|
+
const feature = defineFeature("shop", (r) => {
|
|
154
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
155
|
+
r.screen({
|
|
156
|
+
id: "product-edit",
|
|
157
|
+
type: "entityEdit",
|
|
158
|
+
entity: "product",
|
|
159
|
+
layout: {
|
|
160
|
+
sections: [
|
|
161
|
+
{
|
|
162
|
+
columns: 1,
|
|
163
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
164
|
+
fields: [{ field: "name", renderer: ((v: unknown) => String(v)) as any }],
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
expect(() => validateBoot([feature])).toThrow(/field "name" renderer is a function/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("entityEdit field with declarative visible/readOnly/required → kein Throw", () => {
|
|
174
|
+
const feature = defineFeature("shop", (r) => {
|
|
175
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
176
|
+
r.screen({
|
|
177
|
+
id: "product-edit",
|
|
178
|
+
type: "entityEdit",
|
|
179
|
+
entity: "product",
|
|
180
|
+
layout: {
|
|
181
|
+
sections: [
|
|
182
|
+
{
|
|
183
|
+
columns: 1,
|
|
184
|
+
fields: [
|
|
185
|
+
{
|
|
186
|
+
field: "name",
|
|
187
|
+
visible: { field: "name", ne: "" },
|
|
188
|
+
readOnly: false,
|
|
189
|
+
required: true,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
r.translations({
|
|
197
|
+
keys: {
|
|
198
|
+
"screen:product-edit.title": { de: "Bearbeiten", en: "Edit" },
|
|
199
|
+
"shop:entity:product:field:name": { de: "Name", en: "Name" },
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("projectionList column renderer as function → Throw", () => {
|
|
207
|
+
const feature = defineFeature("shop", (r) => {
|
|
208
|
+
r.screen({
|
|
209
|
+
id: "sales-list",
|
|
210
|
+
type: "projectionList",
|
|
211
|
+
query: "shop:query:sales",
|
|
212
|
+
columns: [
|
|
213
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
214
|
+
{ field: "amount", renderer: ((v: unknown) => String(v)) as any },
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
expect(() => validateBoot([feature])).toThrow(/column "amount" renderer is a function/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("projectionList rowAction payload as function → Throw", () => {
|
|
222
|
+
const feature = defineFeature("shop", (r) => {
|
|
223
|
+
r.screen({
|
|
224
|
+
id: "sales-list",
|
|
225
|
+
type: "projectionList",
|
|
226
|
+
query: "shop:query:sales",
|
|
227
|
+
columns: ["amount"],
|
|
228
|
+
rowActions: [
|
|
229
|
+
{
|
|
230
|
+
kind: "writeHandler",
|
|
231
|
+
id: "sync",
|
|
232
|
+
label: "actions.sync",
|
|
233
|
+
handler: "shop:write:sync",
|
|
234
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
235
|
+
payload: (() => ({})) as any,
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
expect(() => validateBoot([feature])).toThrow(/rowAction "sync" payload is a function/);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -1231,8 +1231,9 @@ describe("boot-validator", () => {
|
|
|
1231
1231
|
// --- entityList column-renderer form-check ---
|
|
1232
1232
|
// Validator akzeptiert die `{ react: { __component: "Name" } }`-Form
|
|
1233
1233
|
// (PlatformComponent → client-side Registry-Lookup) und prüft sie
|
|
1234
|
-
// strukturell.
|
|
1235
|
-
//
|
|
1234
|
+
// strukturell. null/undefined, native-only und andere Formen bleiben
|
|
1235
|
+
// opak. Function-Renderer werfen (action-wiring.ts validateFieldWiring) —
|
|
1236
|
+
// von JSON.stringify verworfen, sonst stiller No-op auf dem Client.
|
|
1236
1237
|
describe("entityList column renderer form", () => {
|
|
1237
1238
|
function shopFeature(renderer: unknown) {
|
|
1238
1239
|
return defineFeature("shop", (r) => {
|
|
@@ -1250,8 +1251,10 @@ describe("boot-validator", () => {
|
|
|
1250
1251
|
});
|
|
1251
1252
|
}
|
|
1252
1253
|
|
|
1253
|
-
test("function-renderer →
|
|
1254
|
-
expect(() => validateBoot([shopFeature((v: unknown) => String(v))])).
|
|
1254
|
+
test("function-renderer → Throw (Function wird von JSON.stringify verworfen)", () => {
|
|
1255
|
+
expect(() => validateBoot([shopFeature((v: unknown) => String(v))])).toThrow(
|
|
1256
|
+
/column "name" renderer is a function/,
|
|
1257
|
+
);
|
|
1255
1258
|
});
|
|
1256
1259
|
|
|
1257
1260
|
test("undefined renderer → kein Throw (Spalte ohne Renderer)", () => {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { EditFieldSpec, FeatureDefinition, RowAction, ToolbarAction } from "../types";
|
|
2
|
+
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
|
|
3
|
+
|
|
4
|
+
const FUNCTION_DROPPED_HINT =
|
|
5
|
+
"functions are dropped by JSON.stringify when the screen config reaches the client " +
|
|
6
|
+
"bundle — the action/field silently no-ops at runtime instead of failing loudly.";
|
|
7
|
+
|
|
8
|
+
function throwIfFunction(value: unknown, message: string): void {
|
|
9
|
+
if (typeof value === "function") {
|
|
10
|
+
throw new Error(message);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// rowActions/toolbarActions carry row-context extractors (payload/params),
|
|
15
|
+
// static visibility conditions and navigate entityId — all declarative-DSL-
|
|
16
|
+
// only fields (RowFieldExtractor's `{ pick }`/`{ map }`, FieldCondition's
|
|
17
|
+
// `{ field, eq }`/`{ field, ne }`, plain strings). A function literal here
|
|
18
|
+
// compiles fine (the DSL types are structural, not branded) but is silently
|
|
19
|
+
// dropped once the feature config is serialized for the client.
|
|
20
|
+
const ACTION_FUNCTION_FIELDS = ["payload", "params", "entityId", "visible"] as const;
|
|
21
|
+
|
|
22
|
+
function validateActionNoFunctions(
|
|
23
|
+
featureName: string,
|
|
24
|
+
screenId: string,
|
|
25
|
+
actionKind: "rowAction" | "toolbarAction",
|
|
26
|
+
action: RowAction | ToolbarAction,
|
|
27
|
+
): void {
|
|
28
|
+
const record = action as unknown as Record<string, unknown>;
|
|
29
|
+
for (const field of ACTION_FUNCTION_FIELDS) {
|
|
30
|
+
throwIfFunction(
|
|
31
|
+
record[field],
|
|
32
|
+
`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${action.id}" ${field} ` +
|
|
33
|
+
`is a function — ${FUNCTION_DROPPED_HINT} Use the declarative DSL ({ pick }, { map }, ` +
|
|
34
|
+
`"fieldName", { field, eq }) instead.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateActionWiring(feature: FeatureDefinition): void {
|
|
40
|
+
for (const screen of Object.values(feature.screens)) {
|
|
41
|
+
if (screen.type !== "entityList" && screen.type !== "projectionList") continue;
|
|
42
|
+
for (const action of screen.rowActions ?? []) {
|
|
43
|
+
validateActionNoFunctions(feature.name, screen.id, "rowAction", action);
|
|
44
|
+
}
|
|
45
|
+
for (const action of screen.toolbarActions ?? []) {
|
|
46
|
+
validateActionNoFunctions(feature.name, screen.id, "toolbarAction", action);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// EditFieldSpec's visible/readOnly/required are the same FieldCondition DSL
|
|
52
|
+
// as rowActions — same footgun, different screen type (entityEdit layouts).
|
|
53
|
+
const EDIT_FIELD_FUNCTION_FIELDS = ["visible", "readOnly", "required"] as const;
|
|
54
|
+
|
|
55
|
+
function validateEditFieldNoFunctions(
|
|
56
|
+
featureName: string,
|
|
57
|
+
screenId: string,
|
|
58
|
+
fieldSpec: EditFieldSpec,
|
|
59
|
+
): void {
|
|
60
|
+
const normalized = normalizeEditField(fieldSpec);
|
|
61
|
+
const record = normalized as unknown as Record<string, unknown>;
|
|
62
|
+
for (const key of EDIT_FIELD_FUNCTION_FIELDS) {
|
|
63
|
+
throwIfFunction(
|
|
64
|
+
record[key],
|
|
65
|
+
`[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${normalized.field}" ` +
|
|
66
|
+
`${key} is a function — ${FUNCTION_DROPPED_HINT} Use a FieldCondition ` +
|
|
67
|
+
`(boolean or { field, eq }/{ field, ne }) instead.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
throwIfFunction(
|
|
71
|
+
normalized.renderer,
|
|
72
|
+
`[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${normalized.field}" ` +
|
|
73
|
+
`renderer is a function — ${FUNCTION_DROPPED_HINT} Use a FormatSpec ({ format: "..." }) instead.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function validateFieldWiring(feature: FeatureDefinition): void {
|
|
78
|
+
for (const screen of Object.values(feature.screens)) {
|
|
79
|
+
if (screen.type === "entityList" || screen.type === "projectionList") {
|
|
80
|
+
for (const col of screen.columns) {
|
|
81
|
+
const normalized = normalizeListColumn(col);
|
|
82
|
+
throwIfFunction(
|
|
83
|
+
normalized.renderer,
|
|
84
|
+
`[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) column ` +
|
|
85
|
+
`"${normalized.field}" renderer is a function — ${FUNCTION_DROPPED_HINT} ` +
|
|
86
|
+
`Use a FormatSpec ({ format: "..." }) instead.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (screen.type !== "entityEdit") continue;
|
|
92
|
+
for (const section of screen.layout.sections) {
|
|
93
|
+
if (isExtensionEditSection(section)) continue;
|
|
94
|
+
for (const fieldSpec of section.fields) {
|
|
95
|
+
validateEditFieldNoFunctions(feature.name, screen.id, fieldSpec);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
|
|
2
2
|
import { QnTypes, qualifyEntityName } from "../qualified-name";
|
|
3
3
|
import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
|
|
4
|
+
import { validateActionWiring, validateFieldWiring } from "./action-wiring";
|
|
4
5
|
import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
|
|
5
6
|
import {
|
|
6
7
|
validateCircularDeps,
|
|
@@ -171,6 +172,12 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
171
172
|
validateConfigKeyBacking(feature);
|
|
172
173
|
validateOwnershipRules(feature, allClaimKeys, knownRoles);
|
|
173
174
|
validateMultiStreamProjections(feature);
|
|
175
|
+
// Vor validateScreens: dessen visible/entityId-Feldref-Checks werfen für
|
|
176
|
+
// einen Function-Wert bereits (mit verwirrender "unknown field undefined"-
|
|
177
|
+
// Message, weil `typeof fn !== "boolean"` true ist), bevor der klare
|
|
178
|
+
// Function-Check hier überhaupt liefe.
|
|
179
|
+
validateActionWiring(feature);
|
|
180
|
+
validateFieldWiring(feature);
|
|
174
181
|
validateScreens(
|
|
175
182
|
feature,
|
|
176
183
|
featureMap,
|
package/src/stack/redis.ts
CHANGED
|
@@ -3,6 +3,13 @@ import { requireEnv } from "./db";
|
|
|
3
3
|
|
|
4
4
|
export type TestRedis = {
|
|
5
5
|
redis: import("ioredis").default;
|
|
6
|
+
// The exact REDIS_URL used to build `redis` above — for a second, unrelated
|
|
7
|
+
// connection (e.g. the test-stack's JobRunner) that needs its own client
|
|
8
|
+
// rather than sharing this one's keyPrefix. Reconstructing a URL from
|
|
9
|
+
// `redis.options` loses password/username/tls/path (pr-review
|
|
10
|
+
// kumiko-framework #1036/2) — callers needing a fresh connection should use
|
|
11
|
+
// this raw string, not rebuild one from parsed options.
|
|
12
|
+
redisUrl: string;
|
|
6
13
|
/** Delete every key this test created (prefix-scoped). Replaces the old
|
|
7
14
|
* `redis.flushdb()` — that wiped other parallel tests' BullMQ state. */
|
|
8
15
|
flushNamespace: () => Promise<void>;
|
|
@@ -35,6 +42,7 @@ export async function createTestRedis(): Promise<TestRedis> {
|
|
|
35
42
|
|
|
36
43
|
return {
|
|
37
44
|
redis,
|
|
45
|
+
redisUrl,
|
|
38
46
|
flushNamespace,
|
|
39
47
|
cleanup: async () => {
|
|
40
48
|
await flushNamespace();
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -237,167 +237,187 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
237
237
|
// test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
|
|
238
238
|
let jobRunner: JobRunner | undefined;
|
|
239
239
|
if (options.jobs && registry.getAllJobs().size > 0) {
|
|
240
|
-
const redisOpts = testRedis.redis.options;
|
|
241
240
|
jobRunner = createJobRunner({
|
|
242
241
|
registry,
|
|
243
242
|
context: { db: testDb.db, registry },
|
|
244
|
-
|
|
243
|
+
// The real REDIS_URL, not one rebuilt from `.options` — that lost
|
|
244
|
+
// password/username/tls/path (pr-review kumiko-framework #1036/2).
|
|
245
|
+
redisUrl: testRedis.redisUrl,
|
|
245
246
|
...(options.jobs.consumerLane !== undefined && { consumerLane: options.jobs.consumerLane }),
|
|
246
247
|
...(options.jobs.queueNamePrefix !== undefined && {
|
|
247
248
|
queueNamePrefix: options.jobs.queueNamePrefix,
|
|
248
249
|
}),
|
|
249
250
|
});
|
|
250
|
-
await jobRunner.start();
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
253
|
+
// From here on, any throw must stop a jobRunner that was already created
|
|
254
|
+
// above (createJobRunner() itself opens live BullMQ Queue/lock Redis
|
|
255
|
+
// connections, .start() adds a live worker) — otherwise a failing
|
|
256
|
+
// buildServer()/search-config/etc. leaks that connection and the test
|
|
257
|
+
// process hangs on exit (pr-review kumiko-framework #1036/1).
|
|
258
|
+
try {
|
|
259
|
+
if (jobRunner) await jobRunner.start();
|
|
260
|
+
|
|
261
|
+
// Auto-configure search for tenant 1 based on registry
|
|
262
|
+
if (enabledHooks.includes("search")) {
|
|
263
|
+
const searchableFields: string[] = [];
|
|
264
|
+
for (const feature of options.features) {
|
|
265
|
+
for (const [, entity] of Object.entries(feature.entities ?? {})) {
|
|
266
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
267
|
+
if (field.type === "text" && field.searchable) {
|
|
268
|
+
searchableFields.push(fieldName);
|
|
269
|
+
}
|
|
270
|
+
if (field.type === "embedded") {
|
|
271
|
+
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
272
|
+
if (subField.searchable) {
|
|
273
|
+
searchableFields.push(`${fieldName}_${subName}`);
|
|
274
|
+
}
|
|
266
275
|
}
|
|
267
276
|
}
|
|
268
277
|
}
|
|
269
278
|
}
|
|
270
279
|
}
|
|
271
|
-
}
|
|
272
280
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
281
|
+
if (options.searchConfig) {
|
|
282
|
+
await searchAdapter.configure(options.searchConfig.tenantId, {
|
|
283
|
+
searchableFields: options.searchConfig.searchableFields,
|
|
284
|
+
rankingFields: options.searchConfig.rankingFields,
|
|
285
|
+
});
|
|
286
|
+
} else if (searchableFields.length > 0) {
|
|
287
|
+
await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
|
|
288
|
+
searchableFields,
|
|
289
|
+
rankingFields: searchableFields,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
283
292
|
}
|
|
284
|
-
}
|
|
285
293
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
294
|
+
// Wire SSE broker with event collector
|
|
295
|
+
const sseBroker = createSseBroker();
|
|
296
|
+
sseBroker.addClient(
|
|
297
|
+
"tenant:00000000-0000-4000-8000-000000000001",
|
|
298
|
+
(event) => events.sse.push(event),
|
|
299
|
+
() => {},
|
|
300
|
+
);
|
|
293
301
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
302
|
+
const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
|
|
303
|
+
const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
|
|
304
|
+
const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
|
|
297
305
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
+
// A static `files.storageProvider` is wired as the per-tenant resolver — the
|
|
307
|
+
// framework test seam that doesn't require mounting config + file-foundation.
|
|
308
|
+
// (Bundled GDPR tests mount the real provider features instead.)
|
|
309
|
+
let fileProviderResolver: import("../files").FileProviderResolver | undefined;
|
|
310
|
+
if (options.files) {
|
|
311
|
+
const provider = options.files.storageProvider;
|
|
312
|
+
fileProviderResolver = () => Promise.resolve(provider);
|
|
313
|
+
}
|
|
306
314
|
|
|
307
|
-
|
|
308
|
-
registry,
|
|
309
|
-
context: {
|
|
310
|
-
db: testDb.db,
|
|
311
|
-
redis: testRedis.redis,
|
|
312
|
-
searchAdapter,
|
|
313
|
-
entityCache,
|
|
315
|
+
const server = buildServer({
|
|
314
316
|
registry,
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
},
|
|
327
|
-
eventDedup,
|
|
328
|
-
sseBroker,
|
|
329
|
-
// Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
|
|
330
|
-
// deterministic drains — no timer-induced flakiness. pollIntervalMs
|
|
331
|
-
// stays short anyway in case a test opts into `.start()`. pgClient
|
|
332
|
-
// plumbs through the LISTEN wake-up for tests that want to measure
|
|
333
|
-
// post-commit latency (Sprint E.4).
|
|
334
|
-
eventDispatcher: {
|
|
335
|
-
pollIntervalMs: 50,
|
|
336
|
-
pgClient: testDb.client as PgClient | undefined,
|
|
337
|
-
systemConsumers: {
|
|
338
|
-
sse: enabledHooks.includes("sse"),
|
|
339
|
-
search: enabledHooks.includes("search"),
|
|
317
|
+
context: {
|
|
318
|
+
db: testDb.db,
|
|
319
|
+
redis: testRedis.redis,
|
|
320
|
+
searchAdapter,
|
|
321
|
+
entityCache,
|
|
322
|
+
registry,
|
|
323
|
+
...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
|
|
324
|
+
...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
|
|
325
|
+
...(typeof options.extraContext === "function"
|
|
326
|
+
? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
|
|
327
|
+
: options.extraContext),
|
|
340
328
|
},
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
329
|
+
jwtSecret,
|
|
330
|
+
dispatcherOptions: {
|
|
331
|
+
idempotency,
|
|
332
|
+
...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
|
|
333
|
+
...(jobRunner && { jobRunner }),
|
|
334
|
+
},
|
|
335
|
+
eventDedup,
|
|
336
|
+
sseBroker,
|
|
337
|
+
// Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
|
|
338
|
+
// deterministic drains — no timer-induced flakiness. pollIntervalMs
|
|
339
|
+
// stays short anyway in case a test opts into `.start()`. pgClient
|
|
340
|
+
// plumbs through the LISTEN wake-up for tests that want to measure
|
|
341
|
+
// post-commit latency (Sprint E.4).
|
|
342
|
+
eventDispatcher: {
|
|
343
|
+
pollIntervalMs: 50,
|
|
344
|
+
pgClient: testDb.client as PgClient | undefined,
|
|
345
|
+
systemConsumers: {
|
|
346
|
+
sse: enabledHooks.includes("sse"),
|
|
347
|
+
search: enabledHooks.includes("search"),
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
// Default tests to no login rate-limiter so existing suites that loop
|
|
351
|
+
// over logins don't hit a 429 after 10 attempts. Suites specifically
|
|
352
|
+
// testing the limiter can override via authConfig.loginRateLimit.
|
|
353
|
+
...(options.authConfig
|
|
354
|
+
? {
|
|
355
|
+
auth: {
|
|
356
|
+
...options.authConfig,
|
|
357
|
+
...(options.authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
: {}),
|
|
361
|
+
...(options.observability ? { observability: options.observability } : {}),
|
|
362
|
+
...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
|
|
363
|
+
...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
|
|
364
|
+
...(options.anonymousAccess
|
|
365
|
+
? {
|
|
366
|
+
anonymousAccess:
|
|
367
|
+
typeof options.anonymousAccess === "function"
|
|
368
|
+
? options.anonymousAccess({
|
|
369
|
+
registry,
|
|
370
|
+
db: testDb.db,
|
|
371
|
+
sseBroker,
|
|
372
|
+
redis: testRedis.redis,
|
|
373
|
+
})
|
|
374
|
+
: options.anonymousAccess,
|
|
375
|
+
}
|
|
376
|
+
: {}),
|
|
377
|
+
});
|
|
370
378
|
|
|
371
|
-
|
|
379
|
+
const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
|
|
372
380
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
381
|
+
// Pre-register consumer state rows so tests can call runOnce() directly
|
|
382
|
+
// without a preceding explicit start(). Timer fires at pollIntervalMs=50
|
|
383
|
+
// but passInFlight serialises concurrent passes — tests that drain via
|
|
384
|
+
// runOnce() remain deterministic. Tests that specifically exercise the
|
|
385
|
+
// timer loop call start() again (idempotent) after setup.
|
|
386
|
+
if (eventDispatcher) await eventDispatcher.ensureRegistered();
|
|
379
387
|
|
|
380
|
-
|
|
388
|
+
const http = createRequestHelper(server.app, server.jwt);
|
|
381
389
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
390
|
+
return {
|
|
391
|
+
app: server.app,
|
|
392
|
+
jwt: server.jwt,
|
|
393
|
+
registry,
|
|
394
|
+
db: testDb.db,
|
|
395
|
+
redis: testRedis,
|
|
396
|
+
search: searchAdapter,
|
|
397
|
+
events,
|
|
398
|
+
http,
|
|
399
|
+
observability: server.observability,
|
|
400
|
+
dispatcher: server.dispatcher,
|
|
401
|
+
...(eventDispatcher ? { eventDispatcher } : {}),
|
|
402
|
+
...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
|
|
403
|
+
...(jobRunner ? { jobRunner } : {}),
|
|
404
|
+
cleanup: async () => {
|
|
405
|
+
if (jobRunner) await jobRunner.stop();
|
|
406
|
+
if (eventDispatcher) await eventDispatcher.stop();
|
|
407
|
+
await server.observability.shutdown();
|
|
408
|
+
await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
} catch (error) {
|
|
412
|
+
// Best-effort — a broken jobRunner.stop() must never mask the real
|
|
413
|
+
// setup failure below.
|
|
414
|
+
if (jobRunner) {
|
|
415
|
+
try {
|
|
416
|
+
await jobRunner.stop();
|
|
417
|
+
} catch {
|
|
418
|
+
// ignore — `error` is the one that matters
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
403
423
|
}
|