@cosmicdrift/kumiko-framework 0.269.2 → 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/README.md +5 -1
- package/package.json +3 -3
- package/src/__tests__/anonymous-access.integration.test.ts +1 -1
- package/src/__tests__/error-contract.integration.test.ts +1 -1
- 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__/fixtures/migrate-open-to-all/manual-only.expected.ts +16 -0
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +16 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.expected.ts +12 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.input.ts +12 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +7 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +7 -0
- package/src/__tests__/full-stack.integration.test.ts +2 -2
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/migrate-open-to-all-codemod.test.ts +84 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/__tests__/transition-guard.integration.test.ts +7 -7
- package/src/api/__tests__/api.test.ts +2 -2
- package/src/api/__tests__/body-limit.test.ts +1 -1
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +2 -2
- package/src/api/__tests__/request-locale.integration.test.ts +1 -1
- package/src/api/__tests__/server-error-logging.test.ts +1 -1
- 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 +99 -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__/list-filter-field-access.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.integration.test.ts +2 -2
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +5 -5
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +108 -17
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +161 -18
- package/src/engine/__tests__/boot-validator-query-refs.test.ts +3 -3
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +69 -18
- package/src/engine/__tests__/build-app-schema.test.ts +10 -6
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +48 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +34 -5
- package/src/engine/__tests__/engine.test.ts +39 -25
- 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 +47 -8
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +2 -2
- 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__/extends-registrar.test.ts +3 -3
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +9 -3
- package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +1 -1
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/lifecycle-hooks.test.ts +9 -3
- package/src/engine/__tests__/multiselect-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/post-query-hook.test.ts +6 -2
- package/src/engine/__tests__/projection-detail-actions.test.ts +2 -2
- package/src/engine/__tests__/projection-detail-singleton.test.ts +3 -3
- package/src/engine/__tests__/projection-detail-tabs.test.ts +3 -3
- package/src/engine/__tests__/projection-list-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/registry.test.ts +19 -13
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen.test.ts +176 -20
- 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__/access-declarations.test.ts +5 -4
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +18 -6
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/access-declarations.ts +3 -5
- 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/nav.ts +4 -3
- 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 +12 -2
- package/src/engine/entity-handlers.ts +72 -20
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +3 -1
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +2 -2
- package/src/engine/feature-ast/__tests__/parse.test.ts +106 -4
- package/src/engine/feature-ast/__tests__/patcher.test.ts +3 -3
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +5 -1
- package/src/engine/feature-ast/extractors/handlers.ts +16 -2
- package/src/engine/feature-ast/extractors/hooks.ts +25 -4
- package/src/engine/feature-ast/patcher.ts +15 -3
- package/src/engine/feature-ast/patterns.ts +4 -0
- package/src/engine/feature-ast/render.ts +3 -0
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/pattern-library/shared-fields.ts +7 -3
- 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/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +3 -3
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -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/es-ops/__tests__/runner.integration.test.ts +2 -2
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-event-trigger.integration.test.ts +2 -2
- package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +2 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +42 -1
- package/src/jobs/job-runner.ts +35 -5
- package/src/observability/__tests__/observability.integration.test.ts +7 -7
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +2 -2
- package/src/pipeline/__tests__/dispatcher.test.ts +15 -15
- 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__/lifecycle-pipeline.test.ts +10 -10
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- 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 +172 -17
- 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 +249 -0
- package/src/secrets/__tests__/contains-secret.test.ts +5 -5
- package/src/stack/__tests__/request-helper.integration.test.ts +3 -3
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +19 -5
- 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
|
@@ -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(
|
|
@@ -149,9 +155,11 @@ describe("defineEntityQueryHandler", () => {
|
|
|
149
155
|
|
|
150
156
|
test("access option is forwarded", () => {
|
|
151
157
|
const def = defineEntityListHandler("note", noteEntity, {
|
|
152
|
-
access: { openToAll:
|
|
158
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
159
|
+
});
|
|
160
|
+
expect(def.access).toEqual({
|
|
161
|
+
openToAll: { reason: "test handler callable by any signed-in test user" },
|
|
153
162
|
});
|
|
154
|
-
expect(def.access).toEqual({ openToAll: true });
|
|
155
163
|
});
|
|
156
164
|
});
|
|
157
165
|
|
|
@@ -180,10 +188,12 @@ describe("defineProjectionQueryHandler", () => {
|
|
|
180
188
|
const def = defineProjectionQueryHandler(
|
|
181
189
|
"revenue:list",
|
|
182
190
|
"showcase:projection:customer-revenue",
|
|
183
|
-
{ access: { openToAll:
|
|
191
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
184
192
|
);
|
|
185
193
|
expect(def.name).toBe("revenue:list");
|
|
186
|
-
expect(def.access).toEqual({
|
|
194
|
+
expect(def.access).toEqual({
|
|
195
|
+
openToAll: { reason: "test handler callable by any signed-in test user" },
|
|
196
|
+
});
|
|
187
197
|
// Empty-object schema — handler takes no payload fields.
|
|
188
198
|
expect(def.schema.safeParse({}).success).toBe(true);
|
|
189
199
|
});
|
|
@@ -192,7 +202,7 @@ describe("defineProjectionQueryHandler", () => {
|
|
|
192
202
|
const def = defineProjectionQueryHandler(
|
|
193
203
|
"revenue:list",
|
|
194
204
|
"showcase:projection:customer-revenue",
|
|
195
|
-
{ access: { openToAll:
|
|
205
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
196
206
|
);
|
|
197
207
|
const fakeRows = [{ customer: "a", totalCents: 100 }];
|
|
198
208
|
const ctx = {
|
|
@@ -215,7 +225,10 @@ describe("defineProjectionQueryHandler", () => {
|
|
|
215
225
|
const def = defineProjectionQueryHandler(
|
|
216
226
|
"revenue:list",
|
|
217
227
|
"showcase:projection:customer-revenue",
|
|
218
|
-
{
|
|
228
|
+
{
|
|
229
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
230
|
+
unsafeAllTenants: true,
|
|
231
|
+
},
|
|
219
232
|
);
|
|
220
233
|
const ctx = { queryProjection: mock().mockResolvedValue([]) };
|
|
221
234
|
await def.handler(
|
|
@@ -235,6 +248,28 @@ describe("defineProjectionQueryHandler", () => {
|
|
|
235
248
|
defineProjectionQueryHandler("revenue:list", "showcase:projection:customer-revenue"),
|
|
236
249
|
).toThrow();
|
|
237
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
|
+
});
|
|
238
273
|
});
|
|
239
274
|
|
|
240
275
|
// Verb-spezifische Wrappers — sind dünne Convenience-Layers über
|
|
@@ -328,7 +363,9 @@ describe("registerEntityCrud", () => {
|
|
|
328
363
|
const { r, entities, writes, queries } = createCrudRegistrarMock();
|
|
329
364
|
registerEntityCrud(r, "note", noteEntitySoftDelete, {
|
|
330
365
|
write: { access: { roles: ["Admin"] } },
|
|
331
|
-
read: {
|
|
366
|
+
read: {
|
|
367
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
368
|
+
},
|
|
332
369
|
});
|
|
333
370
|
expect(entities["note"]).toBe(noteEntitySoftDelete);
|
|
334
371
|
expect(writes.map((w) => w.name)).toEqual([
|
|
@@ -339,7 +376,9 @@ describe("registerEntityCrud", () => {
|
|
|
339
376
|
]);
|
|
340
377
|
expect(queries.map((q) => q.name)).toEqual(["note:list", "note:detail"]);
|
|
341
378
|
expect(writes[0]?.access).toEqual({ roles: ["Admin"] });
|
|
342
|
-
expect(queries[0]?.access).toEqual({
|
|
379
|
+
expect(queries[0]?.access).toEqual({
|
|
380
|
+
openToAll: { reason: "test handler callable by any signed-in test user" },
|
|
381
|
+
});
|
|
343
382
|
});
|
|
344
383
|
|
|
345
384
|
test("verbs.delete: false skips delete handler", () => {
|
|
@@ -50,7 +50,7 @@ const throwOnPreSave: import("../types").PreSaveHookFn = async () => {
|
|
|
50
50
|
const contactFeature = defineFeature("presave-wiring", (r) => {
|
|
51
51
|
r.crud("contact", contactEntity, {
|
|
52
52
|
write: { access: { roles: ["User"] } },
|
|
53
|
-
read: { access: { openToAll:
|
|
53
|
+
read: { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
// preSave has no entity-wide `{ allOf }` shorthand (unlike postSave/
|
|
@@ -70,7 +70,7 @@ const throwingEntity = createEntity({
|
|
|
70
70
|
const throwingFeature = defineFeature("presave-wiring-throw", (r) => {
|
|
71
71
|
r.crud("thing", throwingEntity, {
|
|
72
72
|
write: { access: { roles: ["User"] } },
|
|
73
|
-
read: { access: { openToAll:
|
|
73
|
+
read: { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
74
74
|
});
|
|
75
75
|
r.hook("preSave", "thing:create", throwOnPreSave);
|
|
76
76
|
});
|
|
@@ -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
|
});
|