@cosmicdrift/kumiko-framework 0.270.0 → 0.273.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 +3 -3
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/server.ts +7 -20
- package/src/bun-db/__tests__/query-guards.test.ts +60 -3
- package/src/bun-db/query.ts +24 -21
- package/src/changes.json +91 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
- package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
- package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
- package/src/db/__tests__/tenant-db-global.test.ts +92 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
- package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
- package/src/db/apply-entity-event.ts +1 -1
- package/src/db/assert-exists-in.ts +1 -1
- package/src/db/event-store-executor-context.ts +5 -2
- package/src/db/event-store-executor-read.ts +6 -4
- package/src/db/event-store-executor-write.ts +24 -18
- package/src/db/queries/shadow-swap.ts +36 -0
- package/src/db/replay-migration-sql.ts +5 -5
- package/src/db/tenant-db-runner.ts +23 -0
- package/src/db/tenant-db.ts +97 -24
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
- package/src/engine/__tests__/boot-validator.test.ts +51 -0
- package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
- package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen.test.ts +150 -0
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
- package/src/engine/__tests__/steps-read.test.ts +204 -8
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/global-tenancy.ts +13 -1
- package/src/engine/boot-validator/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +67 -2
- package/src/engine/boot-validator/query-output-columns.ts +33 -0
- package/src/engine/boot-validator/screens.ts +154 -31
- package/src/engine/build-config-feature-schema.ts +7 -4
- package/src/engine/entity-handlers.ts +71 -19
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/registry-validate.ts +12 -4
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-helpers.ts +52 -0
- package/src/engine/steps/_read-source.ts +12 -0
- package/src/engine/steps/read-find-many.ts +8 -2
- package/src/engine/steps/read-find-one.ts +11 -8
- package/src/engine/steps/unsafe-projection-delete.ts +2 -1
- package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
- package/src/engine/types/index.ts +8 -1
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
- package/src/jobs/job-runner.ts +35 -5
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
- package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
- package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
- package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
- package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
- package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
- package/src/pipeline/dispatch-query.ts +27 -8
- package/src/pipeline/dispatch-shared.ts +141 -57
- package/src/pipeline/dispatch-stream.ts +1 -3
- package/src/pipeline/dispatch-write.ts +62 -14
- package/src/pipeline/dispatcher.ts +20 -0
- package/src/pipeline/escape-hatch-report.ts +127 -0
- package/src/pipeline/index.ts +8 -0
- package/src/pipeline/member-read-only-transaction.ts +75 -0
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
- package/src/pipeline/projections-runner.ts +15 -13
- package/src/pipeline/system-identity-switch.ts +121 -9
- package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
- package/src/scripts/codemod/migrate-db-raw.ts +19 -6
- package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
- package/src/ui-types/index.ts +3 -0
- package/src/utils/__tests__/ids.test.ts +29 -1
- package/src/utils/ids.ts +10 -1
- package/src/utils/index.ts +1 -1
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { validateBoot } from "../boot-validator";
|
|
4
4
|
import { defineFeature } from "../define-feature";
|
|
5
|
+
import { createEntity, createTextField } from "../factories";
|
|
5
6
|
|
|
6
7
|
describe("validateBoot — projectionList screens", () => {
|
|
7
8
|
test("rejects hand-written searchable:true when the query schema has no search param (3a)", () => {
|
|
@@ -441,5 +442,95 @@ describe("validateBoot — projectionList screens", () => {
|
|
|
441
442
|
});
|
|
442
443
|
expect(() => validateBoot([feature])).not.toThrow();
|
|
443
444
|
});
|
|
445
|
+
|
|
446
|
+
test("a reference facet targeting an unknown entity is rejected", () => {
|
|
447
|
+
const feature = defineFeature("ledger", (r) => {
|
|
448
|
+
r.queryHandler(
|
|
449
|
+
"schedule:list",
|
|
450
|
+
z.object({ filters: z.unknown().optional() }),
|
|
451
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
452
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
453
|
+
);
|
|
454
|
+
r.screen({
|
|
455
|
+
id: "schedule-list",
|
|
456
|
+
type: "projectionList",
|
|
457
|
+
query: "ledger:query:schedule:list",
|
|
458
|
+
columns: ["tenant"],
|
|
459
|
+
facets: [{ field: "tenant", type: "reference", label: "Tenant", entity: "tenant" }],
|
|
460
|
+
});
|
|
461
|
+
r.translations({
|
|
462
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
expect(() => validateBoot([feature])).toThrow(/does not resolve to a registered entity/);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("a reference facet targeting a known entity passes boot", () => {
|
|
469
|
+
const feature = defineFeature("ledger", (r) => {
|
|
470
|
+
r.entity(
|
|
471
|
+
"tenant",
|
|
472
|
+
createEntity({
|
|
473
|
+
table: "Tenants",
|
|
474
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
475
|
+
}),
|
|
476
|
+
);
|
|
477
|
+
r.queryHandler(
|
|
478
|
+
"schedule:list",
|
|
479
|
+
z.object({ filters: z.unknown().optional() }),
|
|
480
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
481
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
482
|
+
);
|
|
483
|
+
r.screen({
|
|
484
|
+
id: "schedule-list",
|
|
485
|
+
type: "projectionList",
|
|
486
|
+
query: "ledger:query:schedule:list",
|
|
487
|
+
columns: ["tenant"],
|
|
488
|
+
facets: [{ field: "tenant", type: "reference", label: "Tenant", entity: "tenant" }],
|
|
489
|
+
});
|
|
490
|
+
r.translations({
|
|
491
|
+
keys: {
|
|
492
|
+
"screen:schedule-list.title": { de: "Liste", en: "List" },
|
|
493
|
+
"ledger:entity:tenant:field:name": { de: "Name", en: "Name" },
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("a reference facet filtering by an id field with no same-named column boots (displays a different label column)", () => {
|
|
501
|
+
const feature = defineFeature("ledger", (r) => {
|
|
502
|
+
r.entity(
|
|
503
|
+
"property",
|
|
504
|
+
createEntity({
|
|
505
|
+
table: "Properties",
|
|
506
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
509
|
+
r.queryHandler(
|
|
510
|
+
"schedule:list",
|
|
511
|
+
z.object({ filters: z.unknown().optional() }),
|
|
512
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
513
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
514
|
+
);
|
|
515
|
+
r.screen({
|
|
516
|
+
id: "schedule-list",
|
|
517
|
+
type: "projectionList",
|
|
518
|
+
query: "ledger:query:schedule:list",
|
|
519
|
+
// Filters by propertyId, displays propertyLabel — no "propertyId"
|
|
520
|
+
// column exists, and none should be required for a reference facet.
|
|
521
|
+
columns: ["propertyLabel"],
|
|
522
|
+
facets: [
|
|
523
|
+
{ field: "propertyId", type: "reference", label: "Property", entity: "property" },
|
|
524
|
+
],
|
|
525
|
+
});
|
|
526
|
+
r.translations({
|
|
527
|
+
keys: {
|
|
528
|
+
"screen:schedule-list.title": { de: "Liste", en: "List" },
|
|
529
|
+
"ledger:entity:property:field:name": { de: "Name", en: "Name" },
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
534
|
+
});
|
|
444
535
|
});
|
|
445
536
|
});
|
|
@@ -142,6 +142,149 @@ describe("validateBoot — query output schema column refs (fw#2493)", () => {
|
|
|
142
142
|
);
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
test("relatedList toolbarAction visible.field not in the projectionDetail's own outputSchema throws", () => {
|
|
146
|
+
const feature = defineFeature("app", (r) => {
|
|
147
|
+
r.queryHandler("lease:detail", z.object({}), async () => ({ id: "1", status: "active" }), {
|
|
148
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
149
|
+
outputSchema: z.object({ id: z.string(), status: z.string() }),
|
|
150
|
+
});
|
|
151
|
+
r.queryHandler(
|
|
152
|
+
"lease:positions",
|
|
153
|
+
z.object({}),
|
|
154
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
155
|
+
{
|
|
156
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
157
|
+
outputSchema: z.object({
|
|
158
|
+
rows: z.array(z.object({ id: z.string() })),
|
|
159
|
+
nextCursor: z.string().nullable(),
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
r.screen({
|
|
164
|
+
id: "lease-detail",
|
|
165
|
+
type: "projectionDetail",
|
|
166
|
+
query: "app:query:lease:detail",
|
|
167
|
+
layout: {
|
|
168
|
+
sections: [
|
|
169
|
+
{
|
|
170
|
+
kind: "relatedList",
|
|
171
|
+
title: "Positions",
|
|
172
|
+
query: "app:query:lease:positions",
|
|
173
|
+
columns: ["id"],
|
|
174
|
+
toolbarActions: [
|
|
175
|
+
{
|
|
176
|
+
kind: "navigate",
|
|
177
|
+
id: "create",
|
|
178
|
+
label: "Add position",
|
|
179
|
+
screen: "position-create",
|
|
180
|
+
visible: { field: "ghost-status", eq: "active" },
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
189
|
+
/toolbarAction "create" visible\.field references unknown field "ghost-status"/,
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("relatedList toolbarAction navigate params source field not in the projectionDetail's own outputSchema throws", () => {
|
|
194
|
+
const feature = defineFeature("app", (r) => {
|
|
195
|
+
r.queryHandler("lease:detail", z.object({}), async () => ({ id: "1" }), {
|
|
196
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
197
|
+
outputSchema: z.object({ id: z.string() }),
|
|
198
|
+
});
|
|
199
|
+
r.queryHandler(
|
|
200
|
+
"lease:positions",
|
|
201
|
+
z.object({}),
|
|
202
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
203
|
+
{
|
|
204
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
205
|
+
outputSchema: z.object({
|
|
206
|
+
rows: z.array(z.object({ id: z.string() })),
|
|
207
|
+
nextCursor: z.string().nullable(),
|
|
208
|
+
}),
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
r.screen({
|
|
212
|
+
id: "lease-detail",
|
|
213
|
+
type: "projectionDetail",
|
|
214
|
+
query: "app:query:lease:detail",
|
|
215
|
+
layout: {
|
|
216
|
+
sections: [
|
|
217
|
+
{
|
|
218
|
+
kind: "relatedList",
|
|
219
|
+
title: "Positions",
|
|
220
|
+
query: "app:query:lease:positions",
|
|
221
|
+
columns: ["id"],
|
|
222
|
+
toolbarActions: [
|
|
223
|
+
{
|
|
224
|
+
kind: "navigate",
|
|
225
|
+
id: "create",
|
|
226
|
+
label: "Add position",
|
|
227
|
+
screen: "position-create",
|
|
228
|
+
params: { map: { leaseId: "ghost-id" } },
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
237
|
+
/toolbarAction "create" params references unknown field "ghost-id"/,
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("relatedList toolbarAction visible/params present in the projectionDetail's own outputSchema does not throw", () => {
|
|
242
|
+
const feature = defineFeature("app", (r) => {
|
|
243
|
+
r.queryHandler("lease:detail", z.object({}), async () => ({ id: "1", status: "active" }), {
|
|
244
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
245
|
+
outputSchema: z.object({ id: z.string(), status: z.string() }),
|
|
246
|
+
});
|
|
247
|
+
r.queryHandler(
|
|
248
|
+
"lease:positions",
|
|
249
|
+
z.object({}),
|
|
250
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
251
|
+
{
|
|
252
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
253
|
+
outputSchema: z.object({
|
|
254
|
+
rows: z.array(z.object({ id: z.string() })),
|
|
255
|
+
nextCursor: z.string().nullable(),
|
|
256
|
+
}),
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
r.screen({
|
|
260
|
+
id: "lease-detail",
|
|
261
|
+
type: "projectionDetail",
|
|
262
|
+
query: "app:query:lease:detail",
|
|
263
|
+
layout: {
|
|
264
|
+
sections: [
|
|
265
|
+
{
|
|
266
|
+
kind: "relatedList",
|
|
267
|
+
title: "Positions",
|
|
268
|
+
query: "app:query:lease:positions",
|
|
269
|
+
columns: ["id"],
|
|
270
|
+
toolbarActions: [
|
|
271
|
+
{
|
|
272
|
+
kind: "navigate",
|
|
273
|
+
id: "create",
|
|
274
|
+
label: "Add position",
|
|
275
|
+
screen: "position-create",
|
|
276
|
+
visible: { field: "status", eq: "active" },
|
|
277
|
+
params: { map: { leaseId: "id" } },
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
286
|
+
});
|
|
287
|
+
|
|
145
288
|
test("projectionDetail header.title referencing an unknown field throws", () => {
|
|
146
289
|
const feature = defineFeature("app", (r) => {
|
|
147
290
|
r.queryHandler("tenant:detail", z.object({}), async () => ({ id: "1", name: "x" }), {
|
|
@@ -2403,6 +2403,10 @@ describe("boot-validator", () => {
|
|
|
2403
2403
|
readonly sections?: ReadonlyArray<{
|
|
2404
2404
|
readonly title: string;
|
|
2405
2405
|
readonly fields: readonly string[];
|
|
2406
|
+
readonly groups?: ReadonlyArray<{
|
|
2407
|
+
readonly title: string;
|
|
2408
|
+
readonly fields: readonly string[];
|
|
2409
|
+
}>;
|
|
2406
2410
|
}>;
|
|
2407
2411
|
readonly redirect?: string | { readonly screen: string; readonly idFrom: string };
|
|
2408
2412
|
readonly cancelTarget?: string | false;
|
|
@@ -2501,6 +2505,53 @@ describe("boot-validator", () => {
|
|
|
2501
2505
|
).toThrow(/layout references unknown field "ghost"/);
|
|
2502
2506
|
});
|
|
2503
2507
|
|
|
2508
|
+
test("section mit fields UND groups → Throw", () => {
|
|
2509
|
+
expect(() =>
|
|
2510
|
+
validateBoot([
|
|
2511
|
+
makeFeature({
|
|
2512
|
+
sections: [
|
|
2513
|
+
{
|
|
2514
|
+
title: "Approval",
|
|
2515
|
+
fields: ["note"],
|
|
2516
|
+
groups: [{ title: "Priority", fields: ["priority"] }],
|
|
2517
|
+
},
|
|
2518
|
+
],
|
|
2519
|
+
}),
|
|
2520
|
+
]),
|
|
2521
|
+
).toThrow(/declares both fields and groups/);
|
|
2522
|
+
});
|
|
2523
|
+
|
|
2524
|
+
test("group referenziert unknown field → Throw", () => {
|
|
2525
|
+
expect(() =>
|
|
2526
|
+
validateBoot([
|
|
2527
|
+
makeFeature({
|
|
2528
|
+
sections: [
|
|
2529
|
+
{ title: "Approval", fields: [], groups: [{ title: "Priority", fields: ["ghost"] }] },
|
|
2530
|
+
],
|
|
2531
|
+
}),
|
|
2532
|
+
]),
|
|
2533
|
+
).toThrow(/layout references unknown field "ghost"/);
|
|
2534
|
+
});
|
|
2535
|
+
|
|
2536
|
+
test("valide groups → kein Throw", () => {
|
|
2537
|
+
expect(() =>
|
|
2538
|
+
validateBoot([
|
|
2539
|
+
makeFeature({
|
|
2540
|
+
sections: [
|
|
2541
|
+
{
|
|
2542
|
+
title: "Approval",
|
|
2543
|
+
fields: [],
|
|
2544
|
+
groups: [
|
|
2545
|
+
{ title: "Basics", fields: ["note"] },
|
|
2546
|
+
{ title: "Priority", fields: ["priority"] },
|
|
2547
|
+
],
|
|
2548
|
+
},
|
|
2549
|
+
],
|
|
2550
|
+
}),
|
|
2551
|
+
]),
|
|
2552
|
+
).not.toThrow();
|
|
2553
|
+
});
|
|
2554
|
+
|
|
2504
2555
|
test("redirect → existing screen-id im selben feature → kein Throw", () => {
|
|
2505
2556
|
expect(() =>
|
|
2506
2557
|
validateBoot([makeFeature({ redirect: "after-form", extraScreens: ["after-form"] })]),
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The schema lets a caller id through, but only a system identity gets it honored; HTTP users
|
|
2
|
+
// can never present one (SYSTEM_ROLE is reserved), so both halves are pinned end to end here.
|
|
3
|
+
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
6
|
+
import {
|
|
7
|
+
createEntity,
|
|
8
|
+
createSystemUser,
|
|
9
|
+
createTextField,
|
|
10
|
+
defineEntityCreateHandler,
|
|
11
|
+
defineFeature,
|
|
12
|
+
} from "../index";
|
|
13
|
+
|
|
14
|
+
const thingEntity = createEntity({
|
|
15
|
+
table: "callerid_things",
|
|
16
|
+
fields: {
|
|
17
|
+
label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
18
|
+
},
|
|
19
|
+
access: { write: { Admin: "all" } },
|
|
20
|
+
});
|
|
21
|
+
const CREATE_QN = "callerid:write:thing:create";
|
|
22
|
+
|
|
23
|
+
const feature = defineFeature("callerid", (r) => {
|
|
24
|
+
r.entity("thing", thingEntity);
|
|
25
|
+
r.writeHandler(defineEntityCreateHandler("thing", thingEntity, { access: { roles: ["Admin"] } }));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
let stack: TestStack;
|
|
29
|
+
const admin = TestUsers.admin;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
stack = await setupTestStack({ features: [feature] });
|
|
33
|
+
await unsafeCreateEntityTable(stack.db, thingEntity, "thing");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
await stack.cleanup();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("entity create — caller-chosen id", () => {
|
|
41
|
+
test("system-identity caller: the chosen id is honored", async () => {
|
|
42
|
+
const chosenId = "00000000-0000-4000-8000-0000000000c1";
|
|
43
|
+
const systemUser = createSystemUser(admin.tenantId, ["Admin"]);
|
|
44
|
+
const result = await stack.http.writeOk<{ id: string }>(
|
|
45
|
+
CREATE_QN,
|
|
46
|
+
{ label: "from a job", id: chosenId },
|
|
47
|
+
systemUser,
|
|
48
|
+
);
|
|
49
|
+
expect(result.id).toBe(chosenId);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("system-identity caller retrying the same id → version_conflict, no duplicate row created", async () => {
|
|
53
|
+
const chosenId = "00000000-0000-4000-8000-0000000000c2";
|
|
54
|
+
const systemUser = createSystemUser(admin.tenantId, ["Admin"]);
|
|
55
|
+
await stack.http.writeOk<{ id: string }>(
|
|
56
|
+
CREATE_QN,
|
|
57
|
+
{ label: "first attempt", id: chosenId },
|
|
58
|
+
systemUser,
|
|
59
|
+
);
|
|
60
|
+
const retry = await stack.http.writeErr(
|
|
61
|
+
CREATE_QN,
|
|
62
|
+
{ label: "redelivered event", id: chosenId },
|
|
63
|
+
systemUser,
|
|
64
|
+
);
|
|
65
|
+
expect(retry.code).toBe("version_conflict");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("ordinary end user: the payload id is ignored, a fresh id is minted", async () => {
|
|
69
|
+
const chosenId = "00000000-0000-4000-8000-0000000000c3";
|
|
70
|
+
const result = await stack.http.writeOk<{ id: string }>(
|
|
71
|
+
CREATE_QN,
|
|
72
|
+
{ label: "from a browser", id: chosenId },
|
|
73
|
+
admin,
|
|
74
|
+
);
|
|
75
|
+
expect(result.id).not.toBe(chosenId);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("malformed id → validation error, regardless of caller identity", async () => {
|
|
79
|
+
const systemUser = createSystemUser(admin.tenantId, ["Admin"]);
|
|
80
|
+
const err = await stack.http.writeErr(
|
|
81
|
+
CREATE_QN,
|
|
82
|
+
{ label: "bad id", id: "not-a-uuid" },
|
|
83
|
+
systemUser,
|
|
84
|
+
);
|
|
85
|
+
expect(err.code).toBe("validation_error");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
defineEntityListHandler,
|
|
4
|
+
defineEntityUpdateHandler,
|
|
5
|
+
isDeprecatedCrossTenantHandler,
|
|
6
|
+
} from "../entity-handlers";
|
|
7
|
+
import { createEntity, createTextField } from "../factories";
|
|
8
|
+
|
|
9
|
+
const thingEntity = createEntity({
|
|
10
|
+
table: "escape_hatch_things",
|
|
11
|
+
fields: {
|
|
12
|
+
label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const sysadminAccess = { access: { roles: ["SystemAdmin"] } } as const;
|
|
17
|
+
|
|
18
|
+
describe("entity convention handlers: escapeHatch option", () => {
|
|
19
|
+
test("throws when both escapeHatch and the deprecated crossTenant are set", () => {
|
|
20
|
+
expect(() =>
|
|
21
|
+
defineEntityUpdateHandler("thing", thingEntity, {
|
|
22
|
+
...sysadminAccess,
|
|
23
|
+
escapeHatch: { reason: "operator write" },
|
|
24
|
+
crossTenant: true,
|
|
25
|
+
}),
|
|
26
|
+
).toThrow(/declare either escapeHatch or the deprecated crossTenant, not both/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("throws when escapeHatch.reason is empty", () => {
|
|
30
|
+
expect(() =>
|
|
31
|
+
defineEntityUpdateHandler("thing", thingEntity, {
|
|
32
|
+
...sysadminAccess,
|
|
33
|
+
escapeHatch: { reason: "" },
|
|
34
|
+
}),
|
|
35
|
+
).toThrow(/non-empty reason/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("throws when escapeHatch.reason is whitespace-only", () => {
|
|
39
|
+
expect(() =>
|
|
40
|
+
defineEntityListHandler("thing", thingEntity, {
|
|
41
|
+
...sysadminAccess,
|
|
42
|
+
escapeHatch: { reason: " " },
|
|
43
|
+
}),
|
|
44
|
+
).toThrow(/non-empty reason/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("WriteHandlerDef from an escapeHatch update handler carries no escapeHatch field", () => {
|
|
48
|
+
const def = defineEntityUpdateHandler("thing", thingEntity, {
|
|
49
|
+
...sysadminAccess,
|
|
50
|
+
escapeHatch: { reason: "operator cross-tenant write" },
|
|
51
|
+
});
|
|
52
|
+
expect("escapeHatch" in def).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("QueryHandlerDef from an escapeHatch list handler carries no escapeHatch field", () => {
|
|
56
|
+
const def = defineEntityListHandler("thing", thingEntity, {
|
|
57
|
+
...sysadminAccess,
|
|
58
|
+
escapeHatch: { reason: "operator cross-tenant list" },
|
|
59
|
+
});
|
|
60
|
+
expect("escapeHatch" in def).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("WriteHandlerDef from a legacy crossTenant handler also carries no escapeHatch field", () => {
|
|
64
|
+
const def = defineEntityUpdateHandler("thing", thingEntity, {
|
|
65
|
+
...sysadminAccess,
|
|
66
|
+
crossTenant: true,
|
|
67
|
+
});
|
|
68
|
+
expect("escapeHatch" in def).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("isDeprecatedCrossTenantHandler is true only for handlers built with crossTenant: true", () => {
|
|
72
|
+
const legacyWrite = defineEntityUpdateHandler("thing", thingEntity, {
|
|
73
|
+
...sysadminAccess,
|
|
74
|
+
crossTenant: true,
|
|
75
|
+
});
|
|
76
|
+
const escapeHatchWrite = defineEntityUpdateHandler("thing", thingEntity, {
|
|
77
|
+
...sysadminAccess,
|
|
78
|
+
escapeHatch: { reason: "operator cross-tenant write" },
|
|
79
|
+
});
|
|
80
|
+
const plainWrite = defineEntityUpdateHandler("thing", thingEntity, sysadminAccess);
|
|
81
|
+
const legacyList = defineEntityListHandler("thing", thingEntity, {
|
|
82
|
+
...sysadminAccess,
|
|
83
|
+
crossTenant: true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(isDeprecatedCrossTenantHandler(legacyWrite.handler)).toBe(true);
|
|
87
|
+
expect(isDeprecatedCrossTenantHandler(legacyList.handler)).toBe(true);
|
|
88
|
+
expect(isDeprecatedCrossTenantHandler(escapeHatchWrite.handler)).toBe(false);
|
|
89
|
+
expect(isDeprecatedCrossTenantHandler(plainWrite.handler)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -72,6 +72,12 @@ describe("defineEntityWriteHandler", () => {
|
|
|
72
72
|
expect(def.schema.safeParse({}).success).toBe(false);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
test("create: schema accepts an optional caller-chosen id, well-formed UUID only", () => {
|
|
76
|
+
const def = defineEntityCreateHandler("note", noteEntity, adminAccess);
|
|
77
|
+
expect(def.schema.safeParse({ title: "x", id: VALID_UUID }).success).toBe(true);
|
|
78
|
+
expect(def.schema.safeParse({ title: "x", id: "not-a-uuid" }).success).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
75
81
|
test("update: schema requires id + version + changes", () => {
|
|
76
82
|
const def = defineEntityUpdateHandler("note", noteEntity, adminAccess);
|
|
77
83
|
expect(
|
|
@@ -242,6 +248,28 @@ describe("defineProjectionQueryHandler", () => {
|
|
|
242
248
|
defineProjectionQueryHandler("revenue:list", "showcase:projection:customer-revenue"),
|
|
243
249
|
).toThrow();
|
|
244
250
|
});
|
|
251
|
+
|
|
252
|
+
test("escapeHatch is forwarded to the returned def", () => {
|
|
253
|
+
const def = defineProjectionQueryHandler(
|
|
254
|
+
"revenue:list",
|
|
255
|
+
"showcase:projection:customer-revenue",
|
|
256
|
+
{
|
|
257
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
258
|
+
unsafeAllTenants: true,
|
|
259
|
+
escapeHatch: { reason: "fw#2913 test — cross-tenant revenue analytics" },
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
expect(def.escapeHatch).toEqual({ reason: "fw#2913 test — cross-tenant revenue analytics" });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("escapeHatch is omitted from the def when not declared", () => {
|
|
266
|
+
const def = defineProjectionQueryHandler(
|
|
267
|
+
"revenue:list",
|
|
268
|
+
"showcase:projection:customer-revenue",
|
|
269
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
270
|
+
);
|
|
271
|
+
expect(def.escapeHatch).toBeUndefined();
|
|
272
|
+
});
|
|
245
273
|
});
|
|
246
274
|
|
|
247
275
|
// Verb-spezifische Wrappers — sind dünne Convenience-Layers über
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// a soft-deleted row belonging to another tenant and got the decrypted row back
|
|
4
4
|
// in the response. delete() never had that hole (it goes through the
|
|
5
5
|
// tenant-scoped loadById). These tests pin both halves: the tenant-scoped
|
|
6
|
-
// handler must not reach across, and the crossTenant
|
|
7
|
-
// to.
|
|
6
|
+
// handler must not reach across, and the crossTenant/escapeHatch handlers
|
|
7
|
+
// must still be able to.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
// without `crossTenant`,
|
|
9
|
+
// Three stacks on the SAME Postgres database: one registers the restore
|
|
10
|
+
// handler without `crossTenant`/`escapeHatch`, one with the deprecated
|
|
11
|
+
// `crossTenant: true`, one with `escapeHatch: { reason }`.
|
|
11
12
|
|
|
12
13
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
14
|
import { selectMany } from "../../db/query";
|
|
@@ -39,7 +40,9 @@ const thingEntity = createEntity({
|
|
|
39
40
|
});
|
|
40
41
|
const thingTable = buildEntityTable("thing", thingEntity);
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
const ESCAPE_HATCH_REASON = "fw#2915 integration test — operator restores a foreign tenant's row";
|
|
44
|
+
|
|
45
|
+
function buildFeature(crossTenant: boolean, escapeHatch?: boolean) {
|
|
43
46
|
return defineFeature("ctrestore", (r) => {
|
|
44
47
|
r.entity("thing", thingEntity);
|
|
45
48
|
r.writeHandler(
|
|
@@ -56,6 +59,7 @@ function buildFeature(crossTenant: boolean) {
|
|
|
56
59
|
defineEntityRestoreHandler("thing", thingEntity, {
|
|
57
60
|
access: { roles: ["Admin", "SystemAdmin"] },
|
|
58
61
|
...(crossTenant && { crossTenant: true }),
|
|
62
|
+
...(escapeHatch && { escapeHatch: { reason: ESCAPE_HATCH_REASON } }),
|
|
59
63
|
}),
|
|
60
64
|
);
|
|
61
65
|
});
|
|
@@ -65,6 +69,7 @@ const dbName = `kumiko_test_ctrestore_${crypto.randomUUID().replace(/-/g, "").sl
|
|
|
65
69
|
|
|
66
70
|
let tenantScopedStack: TestStack;
|
|
67
71
|
let crossTenantStack: TestStack;
|
|
72
|
+
let escapeHatchStack: TestStack;
|
|
68
73
|
|
|
69
74
|
beforeAll(async () => {
|
|
70
75
|
tenantScopedStack = await setupTestStack({ features: [buildFeature(false)], dbName });
|
|
@@ -74,9 +79,15 @@ beforeAll(async () => {
|
|
|
74
79
|
dbName,
|
|
75
80
|
persistentDb: true,
|
|
76
81
|
});
|
|
82
|
+
escapeHatchStack = await setupTestStack({
|
|
83
|
+
features: [buildFeature(false, true)],
|
|
84
|
+
dbName,
|
|
85
|
+
persistentDb: true,
|
|
86
|
+
});
|
|
77
87
|
});
|
|
78
88
|
|
|
79
89
|
afterAll(async () => {
|
|
90
|
+
await escapeHatchStack.cleanup();
|
|
80
91
|
await crossTenantStack.cleanup();
|
|
81
92
|
await tenantScopedStack.cleanup();
|
|
82
93
|
});
|
|
@@ -140,4 +151,18 @@ describe("entity restore: tenant isolation", () => {
|
|
|
140
151
|
expect(rows[0]?.["isDeleted"]).toBe(false);
|
|
141
152
|
expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
|
|
142
153
|
});
|
|
154
|
+
|
|
155
|
+
test("escapeHatch restore still reaches a foreign tenant's row", async () => {
|
|
156
|
+
const id = await createDeletedThing("escape-hatch-restore");
|
|
157
|
+
|
|
158
|
+
await escapeHatchStack.http.writeOk(
|
|
159
|
+
"ctrestore:write:thing:restore",
|
|
160
|
+
{ id },
|
|
161
|
+
TestUsers.systemAdmin,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
|
|
165
|
+
expect(rows[0]?.["isDeleted"]).toBe(false);
|
|
166
|
+
expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
|
|
167
|
+
});
|
|
143
168
|
});
|