@cosmicdrift/kumiko-framework 0.270.0 → 0.274.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 +105 -0
- package/src/db/__tests__/build-filter-where.test.ts +8 -0
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
- package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
- 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/entity-field-encryption.ts +18 -0
- package/src/db/event-store-executor-context.ts +12 -5
- package/src/db/event-store-executor-read.ts +125 -37
- 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 +71 -1
- package/src/engine/__tests__/engine.test.ts +86 -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-filter-ops.test.ts +1 -1
- 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 +68 -3
- 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 +74 -22
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/registry-validate.ts +36 -6
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-filter-ops.ts +10 -2
- 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
|
@@ -2306,7 +2306,7 @@ describe("boot-validator", () => {
|
|
|
2306
2306
|
function makeFeature(
|
|
2307
2307
|
filter: {
|
|
2308
2308
|
readonly field: string;
|
|
2309
|
-
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
2309
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
|
|
2310
2310
|
readonly value: unknown;
|
|
2311
2311
|
},
|
|
2312
2312
|
fields: Record<string, unknown> = {
|
|
@@ -2365,6 +2365,25 @@ describe("boot-validator", () => {
|
|
|
2365
2365
|
).not.toThrow();
|
|
2366
2366
|
});
|
|
2367
2367
|
|
|
2368
|
+
test("filter.op=lte/gte auf number-Feld → kein Throw (vergleichbar)", () => {
|
|
2369
|
+
const fields = {
|
|
2370
|
+
name: { type: "text", filterable: true },
|
|
2371
|
+
rank: { type: "number", filterable: true },
|
|
2372
|
+
};
|
|
2373
|
+
expect(() =>
|
|
2374
|
+
validateBoot([makeFeature({ field: "rank", op: "lte", value: 5 }, fields)]),
|
|
2375
|
+
).not.toThrow();
|
|
2376
|
+
expect(() =>
|
|
2377
|
+
validateBoot([makeFeature({ field: "rank", op: "gte", value: 5 }, fields)]),
|
|
2378
|
+
).not.toThrow();
|
|
2379
|
+
});
|
|
2380
|
+
|
|
2381
|
+
test("filter.op=lte auf text-Feld → Throw (op-vs-Type-Compat)", () => {
|
|
2382
|
+
expect(() => validateBoot([makeFeature({ field: "status", op: "lte", value: "x" })])).toThrow(
|
|
2383
|
+
/filter\.op "lte" is not allowed on field "status" \(type "text"\)/,
|
|
2384
|
+
);
|
|
2385
|
+
});
|
|
2386
|
+
|
|
2368
2387
|
test('filter.op="in" mit non-array value → Throw', () => {
|
|
2369
2388
|
expect(() =>
|
|
2370
2389
|
validateBoot([makeFeature({ field: "status", op: "in", value: "active" })]),
|
|
@@ -2403,6 +2422,10 @@ describe("boot-validator", () => {
|
|
|
2403
2422
|
readonly sections?: ReadonlyArray<{
|
|
2404
2423
|
readonly title: string;
|
|
2405
2424
|
readonly fields: readonly string[];
|
|
2425
|
+
readonly groups?: ReadonlyArray<{
|
|
2426
|
+
readonly title: string;
|
|
2427
|
+
readonly fields: readonly string[];
|
|
2428
|
+
}>;
|
|
2406
2429
|
}>;
|
|
2407
2430
|
readonly redirect?: string | { readonly screen: string; readonly idFrom: string };
|
|
2408
2431
|
readonly cancelTarget?: string | false;
|
|
@@ -2501,6 +2524,53 @@ describe("boot-validator", () => {
|
|
|
2501
2524
|
).toThrow(/layout references unknown field "ghost"/);
|
|
2502
2525
|
});
|
|
2503
2526
|
|
|
2527
|
+
test("section mit fields UND groups → Throw", () => {
|
|
2528
|
+
expect(() =>
|
|
2529
|
+
validateBoot([
|
|
2530
|
+
makeFeature({
|
|
2531
|
+
sections: [
|
|
2532
|
+
{
|
|
2533
|
+
title: "Approval",
|
|
2534
|
+
fields: ["note"],
|
|
2535
|
+
groups: [{ title: "Priority", fields: ["priority"] }],
|
|
2536
|
+
},
|
|
2537
|
+
],
|
|
2538
|
+
}),
|
|
2539
|
+
]),
|
|
2540
|
+
).toThrow(/declares both fields and groups/);
|
|
2541
|
+
});
|
|
2542
|
+
|
|
2543
|
+
test("group referenziert unknown field → Throw", () => {
|
|
2544
|
+
expect(() =>
|
|
2545
|
+
validateBoot([
|
|
2546
|
+
makeFeature({
|
|
2547
|
+
sections: [
|
|
2548
|
+
{ title: "Approval", fields: [], groups: [{ title: "Priority", fields: ["ghost"] }] },
|
|
2549
|
+
],
|
|
2550
|
+
}),
|
|
2551
|
+
]),
|
|
2552
|
+
).toThrow(/layout references unknown field "ghost"/);
|
|
2553
|
+
});
|
|
2554
|
+
|
|
2555
|
+
test("valide groups → kein Throw", () => {
|
|
2556
|
+
expect(() =>
|
|
2557
|
+
validateBoot([
|
|
2558
|
+
makeFeature({
|
|
2559
|
+
sections: [
|
|
2560
|
+
{
|
|
2561
|
+
title: "Approval",
|
|
2562
|
+
fields: [],
|
|
2563
|
+
groups: [
|
|
2564
|
+
{ title: "Basics", fields: ["note"] },
|
|
2565
|
+
{ title: "Priority", fields: ["priority"] },
|
|
2566
|
+
],
|
|
2567
|
+
},
|
|
2568
|
+
],
|
|
2569
|
+
}),
|
|
2570
|
+
]),
|
|
2571
|
+
).not.toThrow();
|
|
2572
|
+
});
|
|
2573
|
+
|
|
2504
2574
|
test("redirect → existing screen-id im selben feature → kein Throw", () => {
|
|
2505
2575
|
expect(() =>
|
|
2506
2576
|
validateBoot([makeFeature({ redirect: "after-form", extraScreens: ["after-form"] })]),
|
|
@@ -754,6 +754,92 @@ describe("createRegistry", () => {
|
|
|
754
754
|
expect(() => createRegistry([feature])).toThrow(/multiple/);
|
|
755
755
|
});
|
|
756
756
|
|
|
757
|
+
test("throws at boot when a searchable reference targets an encrypted labelField", () => {
|
|
758
|
+
const feature = defineFeature("crm", (r) => {
|
|
759
|
+
r.entity(
|
|
760
|
+
"customer",
|
|
761
|
+
createEntity({
|
|
762
|
+
table: "Customers",
|
|
763
|
+
fields: {
|
|
764
|
+
name: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
|
|
765
|
+
},
|
|
766
|
+
}),
|
|
767
|
+
);
|
|
768
|
+
r.entity(
|
|
769
|
+
"order",
|
|
770
|
+
createEntity({
|
|
771
|
+
table: "Orders",
|
|
772
|
+
fields: {
|
|
773
|
+
customerId: {
|
|
774
|
+
type: "reference",
|
|
775
|
+
entity: "customer",
|
|
776
|
+
labelField: "name",
|
|
777
|
+
searchable: true,
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
}),
|
|
781
|
+
);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
expect(() => createRegistry([feature])).toThrow(/encrypted\/PII/);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test('throws at boot when a searchable reference targets a PII labelField without find: "fuzzy" (no plaintext index)', () => {
|
|
788
|
+
const feature = defineFeature("crm", (r) => {
|
|
789
|
+
r.entity(
|
|
790
|
+
"contact",
|
|
791
|
+
createEntity({
|
|
792
|
+
table: "Contacts",
|
|
793
|
+
fields: { displayName: createTextField({ personal: "self", find: "exact" }) },
|
|
794
|
+
}),
|
|
795
|
+
);
|
|
796
|
+
r.entity(
|
|
797
|
+
"order",
|
|
798
|
+
createEntity({
|
|
799
|
+
table: "Orders",
|
|
800
|
+
fields: {
|
|
801
|
+
contactId: {
|
|
802
|
+
type: "reference",
|
|
803
|
+
entity: "contact",
|
|
804
|
+
labelField: "displayName",
|
|
805
|
+
searchable: true,
|
|
806
|
+
},
|
|
807
|
+
},
|
|
808
|
+
}),
|
|
809
|
+
);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
expect(() => createRegistry([feature])).toThrow(/encrypted\/PII/);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test('does not throw when a searchable reference targets a PII labelField with find: "fuzzy" (derived search index)', () => {
|
|
816
|
+
const feature = defineFeature("crm", (r) => {
|
|
817
|
+
r.entity(
|
|
818
|
+
"contact",
|
|
819
|
+
createEntity({
|
|
820
|
+
table: "Contacts",
|
|
821
|
+
fields: { displayName: createTextField({ personal: "self", find: "fuzzy" }) },
|
|
822
|
+
}),
|
|
823
|
+
);
|
|
824
|
+
r.entity(
|
|
825
|
+
"order",
|
|
826
|
+
createEntity({
|
|
827
|
+
table: "Orders",
|
|
828
|
+
fields: {
|
|
829
|
+
contactId: {
|
|
830
|
+
type: "reference",
|
|
831
|
+
entity: "contact",
|
|
832
|
+
labelField: "displayName",
|
|
833
|
+
searchable: true,
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
}),
|
|
837
|
+
);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
expect(() => createRegistry([feature])).not.toThrow();
|
|
841
|
+
});
|
|
842
|
+
|
|
757
843
|
test("returns sortable reference fields for entity, without leaking them into getSortableFields (fw#2741)", () => {
|
|
758
844
|
const feature = defineFeature("crm", (r) => {
|
|
759
845
|
r.entity(
|
|
@@ -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
|
});
|