@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,149 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EscapeHatchUseEvent } from "../../engine/types";
|
|
3
|
+
import type { Logger } from "../../logging/types";
|
|
4
|
+
import { testTenantId } from "../../stack";
|
|
5
|
+
import {
|
|
6
|
+
createEscapeHatchReportWindow,
|
|
7
|
+
ESCAPE_HATCH_USED_SIGNAL,
|
|
8
|
+
reportEscapeHatchUse,
|
|
9
|
+
} from "../escape-hatch-report";
|
|
10
|
+
|
|
11
|
+
const tenantId = testTenantId(1);
|
|
12
|
+
|
|
13
|
+
function recordingLogger(): Logger & {
|
|
14
|
+
readonly warnCalls: Array<{ msg: string; data?: Record<string, unknown> }>;
|
|
15
|
+
readonly errorCalls: Array<{ msg: string; data?: Record<string, unknown> }>;
|
|
16
|
+
} {
|
|
17
|
+
const warnCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
|
|
18
|
+
const errorCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
|
|
19
|
+
const logger: Logger & {
|
|
20
|
+
warnCalls: typeof warnCalls;
|
|
21
|
+
errorCalls: typeof errorCalls;
|
|
22
|
+
} = {
|
|
23
|
+
warnCalls,
|
|
24
|
+
errorCalls,
|
|
25
|
+
info() {},
|
|
26
|
+
debug() {},
|
|
27
|
+
warn(msg, data) {
|
|
28
|
+
warnCalls.push({ msg, data });
|
|
29
|
+
},
|
|
30
|
+
error(msg, data) {
|
|
31
|
+
errorCalls.push({ msg, data });
|
|
32
|
+
},
|
|
33
|
+
child() {
|
|
34
|
+
return logger;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
return logger;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const baseEvent: EscapeHatchUseEvent = {
|
|
41
|
+
handler: 'write "widgets:write:create"',
|
|
42
|
+
kind: "unsafe-raw",
|
|
43
|
+
reason: "legacy migration read",
|
|
44
|
+
tenantId,
|
|
45
|
+
actor: "user-1",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe("createEscapeHatchReportWindow", () => {
|
|
49
|
+
test("dedups an identical key within the window", () => {
|
|
50
|
+
const window = createEscapeHatchReportWindow({ windowMs: 1000 });
|
|
51
|
+
expect(window.shouldReport("k1", 0)).toBe(true);
|
|
52
|
+
expect(window.shouldReport("k1", 500)).toBe(false);
|
|
53
|
+
expect(window.shouldReport("k1", 999)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("reports again after the window expires", () => {
|
|
57
|
+
const window = createEscapeHatchReportWindow({ windowMs: 1000 });
|
|
58
|
+
expect(window.shouldReport("k1", 0)).toBe(true);
|
|
59
|
+
expect(window.shouldReport("k1", 1000)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("evicts the oldest entry beyond maxEntries", () => {
|
|
63
|
+
const window = createEscapeHatchReportWindow({ windowMs: 1000, maxEntries: 2 });
|
|
64
|
+
expect(window.shouldReport("k1", 0)).toBe(true);
|
|
65
|
+
expect(window.shouldReport("k2", 0)).toBe(true);
|
|
66
|
+
// k3 pushes the cache past its cap — k1 (oldest) is evicted to make room.
|
|
67
|
+
expect(window.shouldReport("k3", 0)).toBe(true);
|
|
68
|
+
// k1 was evicted, so it is treated as new and reports again.
|
|
69
|
+
expect(window.shouldReport("k1", 1)).toBe(true);
|
|
70
|
+
// k3 is still within its window and was not evicted.
|
|
71
|
+
expect(window.shouldReport("k3", 1)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("reportEscapeHatchUse", () => {
|
|
76
|
+
test("sink present — calls the sink with the exact event and does not log.warn", () => {
|
|
77
|
+
const calls: EscapeHatchUseEvent[] = [];
|
|
78
|
+
const sink = async (event: EscapeHatchUseEvent) => {
|
|
79
|
+
calls.push(event);
|
|
80
|
+
};
|
|
81
|
+
const log = recordingLogger();
|
|
82
|
+
|
|
83
|
+
reportEscapeHatchUse(baseEvent, { sink, log });
|
|
84
|
+
|
|
85
|
+
expect(calls).toEqual([baseEvent]);
|
|
86
|
+
expect(log.warnCalls).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("no sink — log.warn called once with ESCAPE_HATCH_USED_SIGNAL and exact fields", () => {
|
|
90
|
+
const log = recordingLogger();
|
|
91
|
+
|
|
92
|
+
reportEscapeHatchUse(baseEvent, { log });
|
|
93
|
+
|
|
94
|
+
expect(log.warnCalls.length).toBe(1);
|
|
95
|
+
expect(log.warnCalls[0]?.msg).toBe(ESCAPE_HATCH_USED_SIGNAL);
|
|
96
|
+
expect(log.warnCalls[0]?.data).toEqual({
|
|
97
|
+
handler: baseEvent.handler,
|
|
98
|
+
kind: baseEvent.kind,
|
|
99
|
+
reason: baseEvent.reason,
|
|
100
|
+
tenantId: baseEvent.tenantId,
|
|
101
|
+
actor: baseEvent.actor,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("no sink, event has a target — target fields are included", () => {
|
|
106
|
+
const log = recordingLogger();
|
|
107
|
+
const eventWithTarget: EscapeHatchUseEvent = {
|
|
108
|
+
...baseEvent,
|
|
109
|
+
kind: "identity-switch",
|
|
110
|
+
target: { id: "user-2", tenantId },
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
reportEscapeHatchUse(eventWithTarget, { log });
|
|
114
|
+
|
|
115
|
+
expect(log.warnCalls[0]?.data).toEqual({
|
|
116
|
+
handler: eventWithTarget.handler,
|
|
117
|
+
kind: eventWithTarget.kind,
|
|
118
|
+
reason: eventWithTarget.reason,
|
|
119
|
+
tenantId: eventWithTarget.tenantId,
|
|
120
|
+
actor: eventWithTarget.actor,
|
|
121
|
+
targetId: "user-2",
|
|
122
|
+
targetTenantId: tenantId,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("sink rejection — logs via log.error", async () => {
|
|
127
|
+
const log = recordingLogger();
|
|
128
|
+
const sink = async () => {
|
|
129
|
+
throw new Error("sink boom");
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
reportEscapeHatchUse(baseEvent, { sink, log });
|
|
133
|
+
// The sink runs async — let its rejection settle before asserting.
|
|
134
|
+
await Promise.resolve();
|
|
135
|
+
await Promise.resolve();
|
|
136
|
+
|
|
137
|
+
expect(log.errorCalls.length).toBe(1);
|
|
138
|
+
expect(log.errorCalls[0]?.msg).toBe(`${ESCAPE_HATCH_USED_SIGNAL}: audit sink failed`);
|
|
139
|
+
expect(log.errorCalls[0]?.data).toEqual({
|
|
140
|
+
handler: baseEvent.handler,
|
|
141
|
+
kind: baseEvent.kind,
|
|
142
|
+
reason: baseEvent.reason,
|
|
143
|
+
tenantId: baseEvent.tenantId,
|
|
144
|
+
actor: baseEvent.actor,
|
|
145
|
+
error: "sink boom",
|
|
146
|
+
});
|
|
147
|
+
expect(log.warnCalls).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -24,7 +24,7 @@ function makeRegistry(hooks?: { preSave?: PreSaveHookFn[]; postSave?: PostSaveHo
|
|
|
24
24
|
);
|
|
25
25
|
// Dummy handler so hook targets resolve (boot validation requires it)
|
|
26
26
|
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
27
|
-
access: { openToAll:
|
|
27
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
28
28
|
});
|
|
29
29
|
if (hooks?.preSave) {
|
|
30
30
|
for (const h of hooks.preSave) r.hook("preSave", "user", h);
|
|
@@ -296,7 +296,7 @@ describe("runPostSave phase routing", () => {
|
|
|
296
296
|
const feature = defineFeature("phases", (r) => {
|
|
297
297
|
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
298
298
|
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
299
|
-
access: { openToAll:
|
|
299
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
300
300
|
});
|
|
301
301
|
r.hook(
|
|
302
302
|
"postSave",
|
|
@@ -325,7 +325,7 @@ describe("runPostSave phase routing", () => {
|
|
|
325
325
|
const feature = defineFeature("phases", (r) => {
|
|
326
326
|
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
327
327
|
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
328
|
-
access: { openToAll:
|
|
328
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
329
329
|
});
|
|
330
330
|
r.hook(
|
|
331
331
|
"postSave",
|
|
@@ -351,7 +351,7 @@ describe("runPostSave phase routing", () => {
|
|
|
351
351
|
const feature = defineFeature("phases", (r) => {
|
|
352
352
|
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
353
353
|
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
354
|
-
access: { openToAll:
|
|
354
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
355
355
|
});
|
|
356
356
|
r.hook("postSave", "user", async () => {
|
|
357
357
|
throw new Error("afterCommit-boom");
|
|
@@ -378,7 +378,7 @@ describe("runPostSave phase routing", () => {
|
|
|
378
378
|
const feature = defineFeature("phases", (r) => {
|
|
379
379
|
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
380
380
|
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
381
|
-
access: { openToAll:
|
|
381
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
382
382
|
});
|
|
383
383
|
});
|
|
384
384
|
const registry = createRegistry([feature]);
|
|
@@ -439,7 +439,7 @@ describe("Sprint 8a: per-tenant entity-hook filter", () => {
|
|
|
439
439
|
"widget:create",
|
|
440
440
|
z.object({ name: z.string() }),
|
|
441
441
|
async () => ({ isSuccess: true as const, data: null }),
|
|
442
|
-
{ access: { openToAll:
|
|
442
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
443
443
|
);
|
|
444
444
|
});
|
|
445
445
|
|
|
@@ -570,7 +570,7 @@ describe("runPreDelete", () => {
|
|
|
570
570
|
}),
|
|
571
571
|
);
|
|
572
572
|
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
573
|
-
access: { openToAll:
|
|
573
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
574
574
|
});
|
|
575
575
|
r.hook("preDelete", "user", async () => {
|
|
576
576
|
calls.push("handler");
|
|
@@ -606,7 +606,7 @@ describe("runPreDelete", () => {
|
|
|
606
606
|
}),
|
|
607
607
|
);
|
|
608
608
|
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
609
|
-
access: { openToAll:
|
|
609
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
610
610
|
});
|
|
611
611
|
r.hook("preDelete", "user", async () => {
|
|
612
612
|
throw new Error("blocked-delete");
|
|
@@ -652,7 +652,7 @@ describe("runPostDelete", () => {
|
|
|
652
652
|
}),
|
|
653
653
|
);
|
|
654
654
|
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
655
|
-
access: { openToAll:
|
|
655
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
656
656
|
});
|
|
657
657
|
r.hook("postDelete", "user", async () => {
|
|
658
658
|
calls.push("feature");
|
|
@@ -685,7 +685,7 @@ describe("runPostDelete", () => {
|
|
|
685
685
|
}),
|
|
686
686
|
);
|
|
687
687
|
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
688
|
-
access: { openToAll:
|
|
688
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
689
689
|
});
|
|
690
690
|
r.hook(
|
|
691
691
|
"postDelete",
|
|
@@ -364,4 +364,32 @@ describe("rebuildMultiStreamProjection — guard rails", () => {
|
|
|
364
364
|
expect(state?.status).toBe("dead");
|
|
365
365
|
expect(state?.lastError).toMatch(/appendEvent/);
|
|
366
366
|
});
|
|
367
|
+
|
|
368
|
+
test("aborts when the live table has RLS enabled — data survives (fw#2907)", async () => {
|
|
369
|
+
const erin = "00000000-0000-4000-8000-000000000e05";
|
|
370
|
+
await updateMany(
|
|
371
|
+
stack.db,
|
|
372
|
+
eventConsumerStateTable,
|
|
373
|
+
{ status: "disabled", updatedAt: sql`now()` },
|
|
374
|
+
{ name: SAGA_MSP },
|
|
375
|
+
);
|
|
376
|
+
await stack.http.writeOk("mspreb:write:invoice:bill", { customer: erin, cents: 20_00 }, admin);
|
|
377
|
+
await runFullDispatcher();
|
|
378
|
+
|
|
379
|
+
await asRawClient(stack.db).unsafe(
|
|
380
|
+
`ALTER TABLE "read_mspreb_balance" ENABLE ROW LEVEL SECURITY`,
|
|
381
|
+
);
|
|
382
|
+
try {
|
|
383
|
+
await expect(
|
|
384
|
+
rebuildMultiStreamProjection(BALANCE_MSP, { db: stack.db, registry: stack.registry }),
|
|
385
|
+
).rejects.toThrow(/row level security/);
|
|
386
|
+
|
|
387
|
+
const [row] = await selectMany(stack.db, balanceTable, { customer: erin });
|
|
388
|
+
expect(row).toMatchObject({ invoicesCents: 20_00 });
|
|
389
|
+
} finally {
|
|
390
|
+
await asRawClient(stack.db).unsafe(
|
|
391
|
+
`ALTER TABLE "read_mspreb_balance" DISABLE ROW LEVEL SECURITY`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
367
395
|
});
|
|
@@ -58,7 +58,7 @@ const postQueryFeature = defineFeature("postquerytest", (r) => {
|
|
|
58
58
|
],
|
|
59
59
|
nextCursor: null,
|
|
60
60
|
}),
|
|
61
|
-
{ access: { openToAll:
|
|
61
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
62
62
|
);
|
|
63
63
|
|
|
64
64
|
// Handler-keyed: fires only for widget:list
|
|
@@ -91,13 +91,13 @@ const duplicateRowHook: PostQueryHookFn = async ({ rows }) => ({ rows: [...rows,
|
|
|
91
91
|
const singleObjectFeature = defineFeature("singleobjtest", (r) => {
|
|
92
92
|
const gadget = r.entity("gadget", gadgetEntity);
|
|
93
93
|
r.queryHandler("gadget:get", z.object({}), async () => ({ id: "g1", name: "Gadget" }), {
|
|
94
|
-
access: { openToAll:
|
|
94
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
95
95
|
});
|
|
96
96
|
r.hook("postQuery", { allOf: gadget }, dropRowHook);
|
|
97
97
|
|
|
98
98
|
const gizmo = r.entity("gizmo", gizmoEntity);
|
|
99
99
|
r.queryHandler("gizmo:get", z.object({}), async () => ({ id: "z1", name: "Gizmo" }), {
|
|
100
|
-
access: { openToAll:
|
|
100
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
101
101
|
});
|
|
102
102
|
r.hook("postQuery", { allOf: gizmo }, duplicateRowHook);
|
|
103
103
|
});
|
|
@@ -938,3 +938,97 @@ describe("rebuildProjection — stale-registry column fence (#835)", () => {
|
|
|
938
938
|
expect(await getCount(group)).toBe(1);
|
|
939
939
|
});
|
|
940
940
|
});
|
|
941
|
+
|
|
942
|
+
describe("rebuildProjection — row level security guard (#2907)", () => {
|
|
943
|
+
test("aborts when the live table has RLS enabled — data and RLS survive", async () => {
|
|
944
|
+
const group = "00000000-0000-4000-8000-000000000051";
|
|
945
|
+
await appendCreatedEvent(group, "item1");
|
|
946
|
+
await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
|
|
947
|
+
expect(await getCount(group)).toBe(1);
|
|
948
|
+
|
|
949
|
+
const raw = asRawClient(testDb.db);
|
|
950
|
+
await raw.unsafe(`ALTER TABLE "read_rebuild_items_per_group" ENABLE ROW LEVEL SECURITY`);
|
|
951
|
+
try {
|
|
952
|
+
await expect(
|
|
953
|
+
rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry }),
|
|
954
|
+
).rejects.toThrow(/read_rebuild_items_per_group.*has row level security/);
|
|
955
|
+
|
|
956
|
+
// Live table untouched: row count AND the RLS flag itself survive.
|
|
957
|
+
expect(await getCount(group)).toBe(1);
|
|
958
|
+
const [pgClass] = await raw.unsafe<{ relrowsecurity: boolean }>(
|
|
959
|
+
`SELECT relrowsecurity FROM pg_class WHERE relname = 'read_rebuild_items_per_group'`,
|
|
960
|
+
);
|
|
961
|
+
expect(pgClass?.relrowsecurity).toBe(true);
|
|
962
|
+
|
|
963
|
+
const state = await getProjectionState(testDb.db, qualifiedProjectionName);
|
|
964
|
+
expect(state?.status).toBe("failed");
|
|
965
|
+
} finally {
|
|
966
|
+
await raw.unsafe(`ALTER TABLE "read_rebuild_items_per_group" DISABLE ROW LEVEL SECURITY`);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// RLS cleared again → rebuild works.
|
|
970
|
+
const result = await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
|
|
971
|
+
expect(result.eventsProcessed).toBe(1);
|
|
972
|
+
expect(await getCount(group)).toBe(1);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test("aborts on a policy alone, without RLS enabled — policy count reported and survives", async () => {
|
|
976
|
+
const group = "00000000-0000-4000-8000-000000000052";
|
|
977
|
+
await appendCreatedEvent(group, "item1");
|
|
978
|
+
await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
|
|
979
|
+
|
|
980
|
+
const raw = asRawClient(testDb.db);
|
|
981
|
+
await raw.unsafe(
|
|
982
|
+
`CREATE POLICY rls_guard_probe ON "read_rebuild_items_per_group" USING (true)`,
|
|
983
|
+
);
|
|
984
|
+
try {
|
|
985
|
+
await expect(
|
|
986
|
+
rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry }),
|
|
987
|
+
).rejects.toThrow(/policies: 1/);
|
|
988
|
+
|
|
989
|
+
const [policyCount] = await raw.unsafe<{ n: number }>(
|
|
990
|
+
`SELECT count(*)::int AS n FROM pg_policy p
|
|
991
|
+
JOIN pg_class c ON c.oid = p.polrelid
|
|
992
|
+
WHERE c.relname = 'read_rebuild_items_per_group'`,
|
|
993
|
+
);
|
|
994
|
+
expect(policyCount?.n).toBe(1);
|
|
995
|
+
} finally {
|
|
996
|
+
await raw.unsafe(`DROP POLICY IF EXISTS rls_guard_probe ON "read_rebuild_items_per_group"`);
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test("aborts on FORCE ROW LEVEL SECURITY alone, without ENABLE", async () => {
|
|
1001
|
+
const raw = asRawClient(testDb.db);
|
|
1002
|
+
await raw.unsafe(`ALTER TABLE "read_rebuild_items_per_group" FORCE ROW LEVEL SECURITY`);
|
|
1003
|
+
try {
|
|
1004
|
+
await expect(
|
|
1005
|
+
rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry }),
|
|
1006
|
+
).rejects.toThrow(/row level security/);
|
|
1007
|
+
} finally {
|
|
1008
|
+
await raw.unsafe(`ALTER TABLE "read_rebuild_items_per_group" NO FORCE ROW LEVEL SECURITY`);
|
|
1009
|
+
await raw.unsafe(`ALTER TABLE "read_rebuild_items_per_group" DISABLE ROW LEVEL SECURITY`);
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
test("swapShadowIntoLive rejects directly when RLS is enabled — live table survives", async () => {
|
|
1014
|
+
const group = "00000000-0000-4000-8000-000000000053";
|
|
1015
|
+
await appendCreatedEvent(group, "item1");
|
|
1016
|
+
await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
|
|
1017
|
+
expect(await getCount(group)).toBe(1);
|
|
1018
|
+
|
|
1019
|
+
const raw = asRawClient(testDb.db);
|
|
1020
|
+
await raw.unsafe(`ALTER TABLE "read_rebuild_items_per_group" ENABLE ROW LEVEL SECURITY`);
|
|
1021
|
+
try {
|
|
1022
|
+
const db = testDb.db as DbConnection; // @cast-boundary test-harness (TestDb.db is intentionally unknown)
|
|
1023
|
+
await expect(
|
|
1024
|
+
db.begin(async (tx: DbTx) => {
|
|
1025
|
+
await swapShadowIntoLive(tx, "read_rebuild_items_per_group");
|
|
1026
|
+
}),
|
|
1027
|
+
).rejects.toThrow(/shadow swap:.*row level security/);
|
|
1028
|
+
|
|
1029
|
+
expect(await getCount(group)).toBe(1);
|
|
1030
|
+
} finally {
|
|
1031
|
+
await raw.unsafe(`ALTER TABLE "read_rebuild_items_per_group" DISABLE ROW LEVEL SECURITY`);
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Pins the in-transaction inline-projection path over real HTTP for an
|
|
2
|
+
// r.systemScope() feature — the tenant-mode path is covered by domain-events-projections.integration.test.ts.
|
|
3
|
+
|
|
4
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import {
|
|
6
|
+
integer as pgInteger,
|
|
7
|
+
table as pgTable,
|
|
8
|
+
text as pgText,
|
|
9
|
+
uuid as pgUuid,
|
|
10
|
+
} from "../../db/dialect";
|
|
11
|
+
import { insertOne, selectMany } from "../../db/query";
|
|
12
|
+
import {
|
|
13
|
+
createEntity,
|
|
14
|
+
createTextField,
|
|
15
|
+
defineEntityCreateHandler,
|
|
16
|
+
defineFeature,
|
|
17
|
+
} from "../../engine";
|
|
18
|
+
import {
|
|
19
|
+
resetEventStore,
|
|
20
|
+
setupTestStack,
|
|
21
|
+
type TestStack,
|
|
22
|
+
TestUsers,
|
|
23
|
+
unsafeCreateEntityTable,
|
|
24
|
+
} from "../../stack";
|
|
25
|
+
|
|
26
|
+
const sysItemEntity = createEntity({
|
|
27
|
+
table: "read_sysproj_items",
|
|
28
|
+
fields: { label: createTextField({ personal: false, reason: "test_fixture", required: true }) },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// No tenant_id column — mirrors the "system-scoped projection" table in
|
|
32
|
+
// query-projection.integration.test.ts, but fed by dispatch-write's
|
|
33
|
+
// in-transaction runProjections (r.projection) rather than a query handler.
|
|
34
|
+
const auditTable = pgTable("read_sysproj_item_audit", {
|
|
35
|
+
itemId: pgUuid("item_id").primaryKey(),
|
|
36
|
+
tenantId: pgUuid("tenant_id").notNull(),
|
|
37
|
+
label: pgText("label").notNull(),
|
|
38
|
+
hits: pgInteger("hits").notNull().default(1),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const sysProjFeature = defineFeature("sysproj", (r) => {
|
|
42
|
+
r.systemScope();
|
|
43
|
+
r.entity("sysproj-item", sysItemEntity);
|
|
44
|
+
|
|
45
|
+
r.projection({
|
|
46
|
+
name: "item-audit",
|
|
47
|
+
source: "sysproj-item",
|
|
48
|
+
table: auditTable,
|
|
49
|
+
apply: {
|
|
50
|
+
"sysproj-item.created": async (event, tx) => {
|
|
51
|
+
const p = event.payload as { label?: string };
|
|
52
|
+
await insertOne(tx, auditTable, {
|
|
53
|
+
itemId: event.aggregateId,
|
|
54
|
+
tenantId: event.tenantId,
|
|
55
|
+
label: p.label ?? "?",
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
r.writeHandler(
|
|
62
|
+
defineEntityCreateHandler("sysproj-item", sysItemEntity, {
|
|
63
|
+
access: { roles: ["SystemAdmin"] },
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let stack: TestStack;
|
|
69
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
70
|
+
|
|
71
|
+
beforeAll(async () => {
|
|
72
|
+
stack = await setupTestStack({ features: [sysProjFeature], systemHooks: [] });
|
|
73
|
+
await unsafeCreateEntityTable(stack.db, sysItemEntity, "sysproj-item");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
await stack.cleanup();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterEach(async () => {
|
|
81
|
+
await resetEventStore(stack, ["read_sysproj_items", "read_sysproj_item_audit"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("inline r.projection fires in-transaction for an r.systemScope() feature", () => {
|
|
85
|
+
test("HTTP write lands both the entity row and the projection row", async () => {
|
|
86
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
87
|
+
"sysproj:write:sysproj-item:create",
|
|
88
|
+
{ label: "widget-a" },
|
|
89
|
+
systemAdmin,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const auditRows = await selectMany<{ itemId: string; label: string; tenantId: string }>(
|
|
93
|
+
stack.db,
|
|
94
|
+
auditTable,
|
|
95
|
+
{ itemId: created.id },
|
|
96
|
+
);
|
|
97
|
+
expect(auditRows).toHaveLength(1);
|
|
98
|
+
expect(auditRows[0]?.label).toBe("widget-a");
|
|
99
|
+
expect(auditRows[0]?.tenantId).toBe(systemAdmin.tenantId);
|
|
100
|
+
});
|
|
101
|
+
});
|