@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
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
// fw#2650 — crossTenant on
|
|
2
|
-
// row that lives in a different tenant
|
|
3
|
-
// two things: (1) an unfiltered db
|
|
4
|
-
// one (so the row is even visible),
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// fw#2650/fw#2915 — crossTenant / escapeHatch on entity convention handlers. A
|
|
2
|
+
// SystemAdmin operator reads/writes a row that lives in a different tenant
|
|
3
|
+
// than their own session. That needs two things: (1) an unfiltered db
|
|
4
|
+
// instead of the caller's tenant-scoped one (so the row is even visible),
|
|
5
|
+
// and (2) the event-store stream AND the entity's tenant-based
|
|
6
|
+
// write-ownership rule — both keyed off the ACTING user, not the db — so the
|
|
7
|
+
// acting user's tenantId must be rewritten to the target row's tenant before
|
|
8
|
+
// the write, not just handed an unfiltered db.
|
|
8
9
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// Three stacks on the SAME Postgres database: "none" (no cross-tenant
|
|
11
|
+
// option), "legacy" (`crossTenant: true`), "escapeHatch"
|
|
12
|
+
// (`escapeHatch: { reason }`) — a clean A/B/C on the one option, no
|
|
13
|
+
// handler-name collisions to work around.
|
|
12
14
|
|
|
13
15
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
16
|
+
import type { EscapeHatchUseEvent } from "@cosmicdrift/kumiko-types/handlers";
|
|
14
17
|
import { selectMany } from "../../db/query";
|
|
15
18
|
import { buildEntityTable } from "../../db/table-builder";
|
|
16
19
|
import { eventsTable } from "../../event-store";
|
|
20
|
+
import type { Logger } from "../../logging/types";
|
|
17
21
|
import {
|
|
18
22
|
setupTestStack,
|
|
19
23
|
type TestStack,
|
|
@@ -21,10 +25,12 @@ import {
|
|
|
21
25
|
testTenantId,
|
|
22
26
|
unsafeCreateEntityTable,
|
|
23
27
|
} from "../../stack";
|
|
28
|
+
import { DEPRECATED_CROSS_TENANT_SIGNAL } from "../entity-handlers";
|
|
24
29
|
import {
|
|
25
30
|
createEntity,
|
|
26
31
|
createTextField,
|
|
27
32
|
defineEntityCreateHandler,
|
|
33
|
+
defineEntityListHandler,
|
|
28
34
|
defineEntityUpdateHandler,
|
|
29
35
|
defineFeature,
|
|
30
36
|
from,
|
|
@@ -32,9 +38,9 @@ import {
|
|
|
32
38
|
|
|
33
39
|
// "Admin" passes unconditionally (any tenant may create its own rows).
|
|
34
40
|
// "SystemAdmin" is tenant-scoped — this is the entity-level rule the
|
|
35
|
-
// crossTenant acting-user rewrite must satisfy for the operator
|
|
36
|
-
// go through; without the rewrite the acting user's own tenant
|
|
37
|
-
// match a foreign row's tenantId here.
|
|
41
|
+
// crossTenant/escapeHatch acting-user rewrite must satisfy for the operator
|
|
42
|
+
// write to go through; without the rewrite the acting user's own tenant
|
|
43
|
+
// would never match a foreign row's tenantId here.
|
|
38
44
|
const thingEntity = createEntity({
|
|
39
45
|
table: "ctwrite_things",
|
|
40
46
|
fields: {
|
|
@@ -44,7 +50,22 @@ const thingEntity = createEntity({
|
|
|
44
50
|
});
|
|
45
51
|
const thingTable = buildEntityTable("thing", thingEntity);
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
const ESCAPE_HATCH_REASON = "fw#2915 integration test — operator cross-tenant scan";
|
|
54
|
+
|
|
55
|
+
type CrossTenantMode = "none" | "legacy" | "escapeHatch";
|
|
56
|
+
|
|
57
|
+
function crossTenantOptionsFor(mode: CrossTenantMode) {
|
|
58
|
+
switch (mode) {
|
|
59
|
+
case "legacy":
|
|
60
|
+
return { crossTenant: true as const };
|
|
61
|
+
case "escapeHatch":
|
|
62
|
+
return { escapeHatch: { reason: ESCAPE_HATCH_REASON } };
|
|
63
|
+
case "none":
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildFeature(mode: CrossTenantMode) {
|
|
48
69
|
return defineFeature("ctwrite", (r) => {
|
|
49
70
|
r.entity("thing", thingEntity);
|
|
50
71
|
r.writeHandler(
|
|
@@ -55,7 +76,13 @@ function buildFeature(crossTenant: boolean) {
|
|
|
55
76
|
r.writeHandler(
|
|
56
77
|
defineEntityUpdateHandler("thing", thingEntity, {
|
|
57
78
|
access: { roles: ["SystemAdmin"] },
|
|
58
|
-
...(
|
|
79
|
+
...crossTenantOptionsFor(mode),
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
r.queryHandler(
|
|
83
|
+
defineEntityListHandler("thing", thingEntity, {
|
|
84
|
+
access: { roles: ["SystemAdmin"] },
|
|
85
|
+
...crossTenantOptionsFor(mode),
|
|
59
86
|
}),
|
|
60
87
|
);
|
|
61
88
|
});
|
|
@@ -63,48 +90,81 @@ function buildFeature(crossTenant: boolean) {
|
|
|
63
90
|
|
|
64
91
|
const dbName = `kumiko_test_ctwrite_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
65
92
|
|
|
66
|
-
|
|
67
|
-
|
|
93
|
+
function recordingSink(events: EscapeHatchUseEvent[]) {
|
|
94
|
+
return async (event: EscapeHatchUseEvent) => {
|
|
95
|
+
events.push(event);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function recordingLogger(sink: Array<{ msg: string; data?: Record<string, unknown> }>): Logger {
|
|
100
|
+
const log: Logger = {
|
|
101
|
+
info() {},
|
|
102
|
+
debug() {},
|
|
103
|
+
warn(msg, data) {
|
|
104
|
+
sink.push({ msg, data });
|
|
105
|
+
},
|
|
106
|
+
error() {},
|
|
107
|
+
child() {
|
|
108
|
+
return log;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
return log;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let noneStack: TestStack;
|
|
115
|
+
let legacyStack: TestStack;
|
|
116
|
+
let escapeHatchStack: TestStack;
|
|
117
|
+
|
|
118
|
+
const legacyWarnCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
|
|
119
|
+
const escapeHatchWarnCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
|
|
120
|
+
const escapeHatchAuditEvents: EscapeHatchUseEvent[] = [];
|
|
68
121
|
|
|
69
122
|
beforeAll(async () => {
|
|
70
|
-
|
|
71
|
-
await unsafeCreateEntityTable(
|
|
72
|
-
// Second
|
|
73
|
-
// cleanup() only closes the pool;
|
|
74
|
-
// flag) owns the actual DROP DATABASE and must clean up last.
|
|
75
|
-
|
|
76
|
-
features: [buildFeature(
|
|
123
|
+
noneStack = await setupTestStack({ features: [buildFeature("none")], dbName });
|
|
124
|
+
await unsafeCreateEntityTable(noneStack.db, thingEntity, "thing");
|
|
125
|
+
// Second and third connections to the SAME database — persistentDb:true
|
|
126
|
+
// means their cleanup() only closes the pool; noneStack (created without
|
|
127
|
+
// that flag) owns the actual DROP DATABASE and must clean up last.
|
|
128
|
+
legacyStack = await setupTestStack({
|
|
129
|
+
features: [buildFeature("legacy")],
|
|
130
|
+
dbName,
|
|
131
|
+
persistentDb: true,
|
|
132
|
+
extraContext: { log: recordingLogger(legacyWarnCalls) },
|
|
133
|
+
});
|
|
134
|
+
escapeHatchStack = await setupTestStack({
|
|
135
|
+
features: [buildFeature("escapeHatch")],
|
|
77
136
|
dbName,
|
|
78
137
|
persistentDb: true,
|
|
138
|
+
extraContext: {
|
|
139
|
+
_escapeHatchAuditSink: recordingSink(escapeHatchAuditEvents),
|
|
140
|
+
log: recordingLogger(escapeHatchWarnCalls),
|
|
141
|
+
},
|
|
79
142
|
});
|
|
80
143
|
});
|
|
81
144
|
|
|
82
145
|
afterAll(async () => {
|
|
83
|
-
await
|
|
84
|
-
await
|
|
146
|
+
await escapeHatchStack.cleanup();
|
|
147
|
+
await legacyStack.cleanup();
|
|
148
|
+
await noneStack.cleanup();
|
|
85
149
|
});
|
|
86
150
|
|
|
87
151
|
async function updatedEventTenantId(aggregateId: string): Promise<string | undefined> {
|
|
88
|
-
const rows = await selectMany<{ type: string; tenantId: string }>(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
{
|
|
92
|
-
aggregateId,
|
|
93
|
-
},
|
|
94
|
-
);
|
|
152
|
+
const rows = await selectMany<{ type: string; tenantId: string }>(noneStack.db, eventsTable, {
|
|
153
|
+
aggregateId,
|
|
154
|
+
});
|
|
95
155
|
const updatedRow = rows.find((r) => r.type.includes("updated"));
|
|
96
156
|
return updatedRow?.tenantId;
|
|
97
157
|
}
|
|
98
158
|
|
|
99
|
-
describe("entity write handlers: crossTenant
|
|
100
|
-
test("crossTenant handler: SystemAdmin updates a row owned by another tenant", async () => {
|
|
101
|
-
const created = await
|
|
159
|
+
describe("entity write/list handlers: crossTenant vs. escapeHatch (fw#2650/fw#2915)", () => {
|
|
160
|
+
test("legacy crossTenant handler: SystemAdmin updates a row owned by another tenant", async () => {
|
|
161
|
+
const created = await noneStack.http.writeOk<{ id: string }>(
|
|
102
162
|
"ctwrite:write:thing:create",
|
|
103
163
|
{ label: "foreign" },
|
|
104
164
|
TestUsers.otherTenant,
|
|
105
165
|
);
|
|
106
166
|
|
|
107
|
-
const updated = await
|
|
167
|
+
const updated = await legacyStack.http.writeOk<{ id: string; data: { label: string } }>(
|
|
108
168
|
"ctwrite:write:thing:update",
|
|
109
169
|
{ id: created.id, version: 1, changes: { label: "touched by operator" } },
|
|
110
170
|
TestUsers.systemAdmin,
|
|
@@ -113,7 +173,7 @@ describe("entity write handlers: crossTenant option (fw#2650)", () => {
|
|
|
113
173
|
|
|
114
174
|
// Row stays on its original tenant — crossTenant reaches across the
|
|
115
175
|
// tenant boundary, it does not migrate the row.
|
|
116
|
-
const rows = await selectMany(
|
|
176
|
+
const rows = await selectMany(noneStack.db, thingTable, { id: created.id });
|
|
117
177
|
expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
|
|
118
178
|
|
|
119
179
|
// The append must land on the row's own tenant stream, not the acting
|
|
@@ -123,36 +183,135 @@ describe("entity write handlers: crossTenant option (fw#2650)", () => {
|
|
|
123
183
|
expect(await updatedEventTenantId(created.id)).toBe(testTenantId(2));
|
|
124
184
|
});
|
|
125
185
|
|
|
186
|
+
test("escapeHatch handler: SystemAdmin updates a row owned by another tenant, reported as acknowledge-cross-tenant", async () => {
|
|
187
|
+
escapeHatchAuditEvents.length = 0;
|
|
188
|
+
const created = await noneStack.http.writeOk<{ id: string }>(
|
|
189
|
+
"ctwrite:write:thing:create",
|
|
190
|
+
{ label: "foreign-escape-hatch" },
|
|
191
|
+
TestUsers.otherTenant,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const updated = await escapeHatchStack.http.writeOk<{ id: string; data: { label: string } }>(
|
|
195
|
+
"ctwrite:write:thing:update",
|
|
196
|
+
{ id: created.id, version: 1, changes: { label: "touched by operator via escapeHatch" } },
|
|
197
|
+
TestUsers.systemAdmin,
|
|
198
|
+
);
|
|
199
|
+
expect(updated.data.label).toBe("touched by operator via escapeHatch");
|
|
200
|
+
|
|
201
|
+
const rows = await selectMany(noneStack.db, thingTable, { id: created.id });
|
|
202
|
+
expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
|
|
203
|
+
expect(await updatedEventTenantId(created.id)).toBe(testTenantId(2));
|
|
204
|
+
|
|
205
|
+
const matches = escapeHatchAuditEvents.filter((e) => e.kind === "acknowledge-cross-tenant");
|
|
206
|
+
expect(matches).toEqual([
|
|
207
|
+
{
|
|
208
|
+
handler: "ctwrite:write:thing:update",
|
|
209
|
+
kind: "acknowledge-cross-tenant",
|
|
210
|
+
reason: ESCAPE_HATCH_REASON,
|
|
211
|
+
tenantId: TestUsers.systemAdmin.tenantId,
|
|
212
|
+
actor: TestUsers.systemAdmin.id,
|
|
213
|
+
},
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("escapeHatch list: SystemAdmin sees rows from every tenant, reported as acknowledge-cross-tenant", async () => {
|
|
218
|
+
escapeHatchAuditEvents.length = 0;
|
|
219
|
+
await noneStack.http.writeOk(
|
|
220
|
+
"ctwrite:write:thing:create",
|
|
221
|
+
{ label: "own-tenant-row" },
|
|
222
|
+
TestUsers.admin,
|
|
223
|
+
);
|
|
224
|
+
await noneStack.http.writeOk(
|
|
225
|
+
"ctwrite:write:thing:create",
|
|
226
|
+
{ label: "other-tenant-row" },
|
|
227
|
+
TestUsers.otherTenant,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const list = await escapeHatchStack.http.queryOk<{
|
|
231
|
+
rows: ReadonlyArray<{ tenantId: string }>;
|
|
232
|
+
}>("ctwrite:query:thing:list", {}, TestUsers.systemAdmin);
|
|
233
|
+
const tenantIds = new Set(list.rows.map((r) => r.tenantId));
|
|
234
|
+
expect(tenantIds.has(testTenantId(1))).toBe(true);
|
|
235
|
+
expect(tenantIds.has(testTenantId(2))).toBe(true);
|
|
236
|
+
|
|
237
|
+
const matches = escapeHatchAuditEvents.filter((e) => e.kind === "acknowledge-cross-tenant");
|
|
238
|
+
expect(matches).toEqual([
|
|
239
|
+
{
|
|
240
|
+
handler: "ctwrite:query:thing:list",
|
|
241
|
+
kind: "acknowledge-cross-tenant",
|
|
242
|
+
reason: ESCAPE_HATCH_REASON,
|
|
243
|
+
tenantId: TestUsers.systemAdmin.tenantId,
|
|
244
|
+
actor: TestUsers.systemAdmin.id,
|
|
245
|
+
},
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("none: list returns only the caller's own tenant rows", async () => {
|
|
250
|
+
const created = await noneStack.http.writeOk<{ id: string }>(
|
|
251
|
+
"ctwrite:write:thing:create",
|
|
252
|
+
{ label: "none-mode-own-tenant" },
|
|
253
|
+
TestUsers.admin,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const list = await noneStack.http.queryOk<{
|
|
257
|
+
rows: ReadonlyArray<{ id: string; tenantId: string }>;
|
|
258
|
+
}>("ctwrite:query:thing:list", {}, TestUsers.systemAdmin);
|
|
259
|
+
expect(list.rows.some((r) => r.id === created.id)).toBe(true);
|
|
260
|
+
expect(list.rows.every((r) => r.tenantId === testTenantId(1))).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
126
263
|
test("normal (non-crossTenant) handler cannot reach a row in another tenant", async () => {
|
|
127
|
-
const created = await
|
|
264
|
+
const created = await noneStack.http.writeOk<{ id: string }>(
|
|
128
265
|
"ctwrite:write:thing:create",
|
|
129
266
|
{ label: "foreign-untouched" },
|
|
130
267
|
TestUsers.otherTenant,
|
|
131
268
|
);
|
|
132
269
|
|
|
133
|
-
const err = await
|
|
270
|
+
const err = await noneStack.http.writeErr(
|
|
134
271
|
"ctwrite:write:thing:update",
|
|
135
272
|
{ id: created.id, version: 1, changes: { label: "nope" } },
|
|
136
273
|
TestUsers.systemAdmin,
|
|
137
274
|
);
|
|
138
275
|
expect(err.code).toBe("not_found");
|
|
139
276
|
|
|
140
|
-
const rows = await selectMany(
|
|
277
|
+
const rows = await selectMany(noneStack.db, thingTable, { id: created.id });
|
|
141
278
|
expect(rows[0]?.["label"]).toBe("foreign-untouched");
|
|
142
279
|
});
|
|
143
280
|
|
|
144
|
-
test("crossTenant still
|
|
145
|
-
const
|
|
281
|
+
test("crossTenant/escapeHatch still gate on access — a role without SystemAdmin is denied", async () => {
|
|
282
|
+
const createdLegacy = await noneStack.http.writeOk<{ id: string }>(
|
|
146
283
|
"ctwrite:write:thing:create",
|
|
147
|
-
{ label: "foreign-denied" },
|
|
284
|
+
{ label: "foreign-denied-legacy" },
|
|
148
285
|
TestUsers.otherTenant,
|
|
149
286
|
);
|
|
287
|
+
const errLegacy = await legacyStack.http.writeErr(
|
|
288
|
+
"ctwrite:write:thing:update",
|
|
289
|
+
{ id: createdLegacy.id, version: 1, changes: { label: "nope" } },
|
|
290
|
+
TestUsers.admin,
|
|
291
|
+
);
|
|
292
|
+
expect(errLegacy.code).toBe("access_denied");
|
|
150
293
|
|
|
151
|
-
const
|
|
294
|
+
const createdEscapeHatch = await noneStack.http.writeOk<{ id: string }>(
|
|
295
|
+
"ctwrite:write:thing:create",
|
|
296
|
+
{ label: "foreign-denied-escape-hatch" },
|
|
297
|
+
TestUsers.otherTenant,
|
|
298
|
+
);
|
|
299
|
+
const errEscapeHatch = await escapeHatchStack.http.writeErr(
|
|
152
300
|
"ctwrite:write:thing:update",
|
|
153
|
-
{ id:
|
|
301
|
+
{ id: createdEscapeHatch.id, version: 1, changes: { label: "nope" } },
|
|
154
302
|
TestUsers.admin,
|
|
155
303
|
);
|
|
156
|
-
expect(
|
|
304
|
+
expect(errEscapeHatch.code).toBe("access_denied");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("legacy stack warns once per deprecated crossTenant handler at boot; the escapeHatch stack does not", () => {
|
|
308
|
+
const legacyMatches = legacyWarnCalls.filter((c) => c.msg === DEPRECATED_CROSS_TENANT_SIGNAL);
|
|
309
|
+
const legacyHandlers = legacyMatches.map((c) => c.data?.["handler"]).sort();
|
|
310
|
+
expect(legacyHandlers).toEqual(["ctwrite:query:thing:list", "ctwrite:write:thing:update"]);
|
|
311
|
+
|
|
312
|
+
const escapeHatchMatches = escapeHatchWarnCalls.filter(
|
|
313
|
+
(c) => c.msg === DEPRECATED_CROSS_TENANT_SIGNAL,
|
|
314
|
+
);
|
|
315
|
+
expect(escapeHatchMatches).toEqual([]);
|
|
157
316
|
});
|
|
158
317
|
});
|
|
@@ -142,7 +142,7 @@ describe("extendsRegistrar", () => {
|
|
|
142
142
|
isSuccess: true as const,
|
|
143
143
|
data: { id: "c1" },
|
|
144
144
|
}),
|
|
145
|
-
{ access: { openToAll:
|
|
145
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
146
146
|
);
|
|
147
147
|
r.useExtension("audited", "credit");
|
|
148
148
|
});
|
|
@@ -181,7 +181,7 @@ describe("extendsRegistrar", () => {
|
|
|
181
181
|
isSuccess: true as const,
|
|
182
182
|
data: { id: "v1" },
|
|
183
183
|
}),
|
|
184
|
-
{ access: { openToAll:
|
|
184
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
185
185
|
);
|
|
186
186
|
r.writeHandler(
|
|
187
187
|
"vehicle:update",
|
|
@@ -190,7 +190,7 @@ describe("extendsRegistrar", () => {
|
|
|
190
190
|
isSuccess: true as const,
|
|
191
191
|
data: { id: "v1" },
|
|
192
192
|
}),
|
|
193
|
-
{ access: { openToAll:
|
|
193
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
194
194
|
);
|
|
195
195
|
r.useExtension("audited", "vehicle");
|
|
196
196
|
});
|
|
@@ -11,7 +11,9 @@ const taskEntity = createEntity({
|
|
|
11
11
|
describe("r.crud", () => {
|
|
12
12
|
test("registers the entity + full CRUD handler set, same as registerEntityCrud", () => {
|
|
13
13
|
const write = { access: { roles: ["Admin"] } } as const;
|
|
14
|
-
const read = {
|
|
14
|
+
const read = {
|
|
15
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
16
|
+
} as const;
|
|
15
17
|
|
|
16
18
|
const feature = defineFeature("via-crud", (r) => {
|
|
17
19
|
r.crud("task", taskEntity, { write, read });
|
|
@@ -28,7 +30,9 @@ describe("r.crud", () => {
|
|
|
28
30
|
|
|
29
31
|
test("without verbAccess, write handlers keep write.access (never fall back to the broader read.access)", () => {
|
|
30
32
|
const write = { access: { roles: ["Manager"] } } as const;
|
|
31
|
-
const read = {
|
|
33
|
+
const read = {
|
|
34
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
35
|
+
} as const;
|
|
32
36
|
|
|
33
37
|
const feature = defineFeature("via-crud-no-verb-access", (r) => {
|
|
34
38
|
r.crud("task", taskEntity, { write, read });
|
|
@@ -44,7 +48,9 @@ describe("r.crud", () => {
|
|
|
44
48
|
|
|
45
49
|
test("verbAccess overrides access per verb, other verbs keep write/read.access", () => {
|
|
46
50
|
const write = { access: { roles: ["Manager"] } } as const;
|
|
47
|
-
const read = {
|
|
51
|
+
const read = {
|
|
52
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
53
|
+
} as const;
|
|
48
54
|
const deleteAccess = { roles: ["Operator"] } as const;
|
|
49
55
|
const restoreAccess = { roles: ["Operator"] } as const;
|
|
50
56
|
const listAccess = { roles: ["Auditor"] } as const;
|
|
@@ -13,7 +13,7 @@ const propertyEntity = createEntity({
|
|
|
13
13
|
const propertyFeature = defineFeature("crud-verb-access", (r) => {
|
|
14
14
|
r.crud("property", propertyEntity, {
|
|
15
15
|
write: { access: { roles: ["User"] } },
|
|
16
|
-
read: { access: { openToAll:
|
|
16
|
+
read: { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
17
17
|
verbAccess: {
|
|
18
18
|
delete: { roles: ["Admin"] },
|
|
19
19
|
restore: { roles: ["Admin"] },
|
|
@@ -27,7 +27,7 @@ describe("HookPhases defaults", () => {
|
|
|
27
27
|
const feature = defineFeature("test", (r) => {
|
|
28
28
|
r.entity("thing", createEntity({ table: "things", fields: {} }));
|
|
29
29
|
r.writeHandler("thing:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
30
|
-
access: { openToAll:
|
|
30
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
31
31
|
});
|
|
32
32
|
r.hook("postSave", "thing:create", noopSave);
|
|
33
33
|
});
|
|
@@ -41,7 +41,7 @@ describe("HookPhases defaults", () => {
|
|
|
41
41
|
const feature = defineFeature("test", (r) => {
|
|
42
42
|
r.entity("thing", createEntity({ table: "things", fields: {} }));
|
|
43
43
|
r.writeHandler("thing:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
44
|
-
access: { openToAll:
|
|
44
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
45
45
|
});
|
|
46
46
|
r.hook("postSave", "thing:create", noopSave, { phase: HookPhases.inTransaction });
|
|
47
47
|
});
|
|
@@ -60,7 +60,7 @@ describe("HookPhases defaults", () => {
|
|
|
60
60
|
isSuccess: true,
|
|
61
61
|
data: null,
|
|
62
62
|
}),
|
|
63
|
-
{ access: { openToAll:
|
|
63
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
64
64
|
);
|
|
65
65
|
r.hook("preDelete", "thing:delete", async () => undefined);
|
|
66
66
|
});
|
|
@@ -95,7 +95,7 @@ describe("Registry phase filtering", () => {
|
|
|
95
95
|
const feature = defineFeature("test", (r) => {
|
|
96
96
|
r.entity("thing", createEntity({ table: "things", fields: {} }));
|
|
97
97
|
r.writeHandler("thing:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
98
|
-
access: { openToAll:
|
|
98
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
99
99
|
});
|
|
100
100
|
r.hook("postSave", "thing:create", inTxFn, { phase: HookPhases.inTransaction });
|
|
101
101
|
r.hook("postSave", "thing:create", afterFn); // default afterCommit
|
|
@@ -127,7 +127,7 @@ describe("Registry phase filtering", () => {
|
|
|
127
127
|
const feature = defineFeature("test", (r) => {
|
|
128
128
|
r.entity("thing", createEntity({ table: "things", fields: {} }));
|
|
129
129
|
r.writeHandler("thing:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
130
|
-
access: { openToAll:
|
|
130
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
131
131
|
});
|
|
132
132
|
});
|
|
133
133
|
|
|
@@ -69,7 +69,9 @@ describe("lifecycle hooks in registry", () => {
|
|
|
69
69
|
test("merges preSave hooks within same feature", () => {
|
|
70
70
|
const f1 = defineFeature("a", (r) => {
|
|
71
71
|
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
72
|
-
r.writeHandler("user", z.object({}), stubHandler, {
|
|
72
|
+
r.writeHandler("user", z.object({}), stubHandler, {
|
|
73
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
74
|
+
});
|
|
73
75
|
r.hook("preSave", "user", async (changes) => changes);
|
|
74
76
|
r.hook("preSave", "user", async (changes) => changes);
|
|
75
77
|
});
|
|
@@ -81,12 +83,16 @@ describe("lifecycle hooks in registry", () => {
|
|
|
81
83
|
test("cross-feature hooks use full prefixed name", () => {
|
|
82
84
|
const f1 = defineFeature("a", (r) => {
|
|
83
85
|
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
84
|
-
r.writeHandler("user", z.object({}), stubHandler, {
|
|
86
|
+
r.writeHandler("user", z.object({}), stubHandler, {
|
|
87
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
88
|
+
});
|
|
85
89
|
r.hook("postSave", "user", async () => {});
|
|
86
90
|
});
|
|
87
91
|
// Feature b hooks into a.user by using the full prefixed name
|
|
88
92
|
const f2 = defineFeature("b", (r) => {
|
|
89
|
-
r.writeHandler("a.user", z.object({}), stubHandler, {
|
|
93
|
+
r.writeHandler("a.user", z.object({}), stubHandler, {
|
|
94
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
95
|
+
});
|
|
90
96
|
r.hook("postSave", "a.user", async () => {});
|
|
91
97
|
r.hook("postSave", "a.user", async () => {});
|
|
92
98
|
});
|
|
@@ -28,7 +28,7 @@ const LIST_QN = "checklist:query:equipment:list";
|
|
|
28
28
|
const checklistFeature = defineFeature("checklist", (r) => {
|
|
29
29
|
r.crud("equipment", equipmentEntity, {
|
|
30
30
|
write: { access: { roles: ["Admin"] } },
|
|
31
|
-
read: { access: { openToAll:
|
|
31
|
+
read: { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
34
|
|
|
@@ -24,7 +24,9 @@ describe("postQuery hook registration", () => {
|
|
|
24
24
|
test("r.hook('postQuery', handlerQn, fn) lands in handler-keyed lifecycleHooks map", () => {
|
|
25
25
|
const feature = defineFeature("test", (r) => {
|
|
26
26
|
r.entity("thing", createEntity({ table: "things", fields: {} }));
|
|
27
|
-
r.queryHandler("thing:list", z.object({}), async () => [], {
|
|
27
|
+
r.queryHandler("thing:list", z.object({}), async () => [], {
|
|
28
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
29
|
+
});
|
|
28
30
|
r.hook("postQuery", "thing:list", noop);
|
|
29
31
|
});
|
|
30
32
|
|
|
@@ -70,7 +72,9 @@ describe("Registry getters", () => {
|
|
|
70
72
|
|
|
71
73
|
const feature = defineFeature("test", (r) => {
|
|
72
74
|
r.entity("thing", createEntity({ table: "things", fields: {} }));
|
|
73
|
-
r.queryHandler("thing:list", z.object({}), async () => [], {
|
|
75
|
+
r.queryHandler("thing:list", z.object({}), async () => [], {
|
|
76
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
77
|
+
});
|
|
74
78
|
r.hook("postQuery", "thing:list", fn);
|
|
75
79
|
});
|
|
76
80
|
|
|
@@ -42,13 +42,13 @@ describe("validateBoot — projectionDetail actions (fw#2166)", () => {
|
|
|
42
42
|
test("valid navigate + writeHandler actions boot cleanly", () => {
|
|
43
43
|
const feature = defineFeature("app", (r) => {
|
|
44
44
|
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
45
|
-
access: { openToAll:
|
|
45
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
46
46
|
});
|
|
47
47
|
r.writeHandler(
|
|
48
48
|
"archive",
|
|
49
49
|
z.object({ id: z.string() }),
|
|
50
50
|
async () => ({ isSuccess: true as const, data: {} }),
|
|
51
|
-
{ access: { openToAll:
|
|
51
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
52
52
|
);
|
|
53
53
|
r.screen({
|
|
54
54
|
id: "rent-detail",
|
|
@@ -21,7 +21,7 @@ describe("validateBoot — projectionDetail singleton (fw#2312)", () => {
|
|
|
21
21
|
test("singleton + idParam throws", () => {
|
|
22
22
|
const feature = defineFeature("app", (r) => {
|
|
23
23
|
r.queryHandler("me", z.object({}), async () => ({ id: "u1" }), {
|
|
24
|
-
access: { openToAll:
|
|
24
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
25
25
|
});
|
|
26
26
|
r.screen({
|
|
27
27
|
id: "profile",
|
|
@@ -46,7 +46,7 @@ describe("validateBoot — projectionDetail singleton (fw#2312)", () => {
|
|
|
46
46
|
}),
|
|
47
47
|
);
|
|
48
48
|
r.queryHandler("me", z.object({}), async () => ({ id: "u1" }), {
|
|
49
|
-
access: { openToAll:
|
|
49
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
50
50
|
});
|
|
51
51
|
r.screen({
|
|
52
52
|
id: "profile",
|
|
@@ -71,7 +71,7 @@ describe("validateBoot — projectionDetail singleton (fw#2312)", () => {
|
|
|
71
71
|
test("singleton without idParam/detailFor boots cleanly", () => {
|
|
72
72
|
const feature = defineFeature("app", (r) => {
|
|
73
73
|
r.queryHandler("me", z.object({}), async () => ({ id: "u1" }), {
|
|
74
|
-
access: { openToAll:
|
|
74
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
75
75
|
});
|
|
76
76
|
r.screen({
|
|
77
77
|
id: "profile",
|
|
@@ -140,7 +140,7 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
|
|
|
140
140
|
test("object-form metric with its own label needs no fieldLabels entry", () => {
|
|
141
141
|
const feature = defineFeature("app", (r) => {
|
|
142
142
|
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
143
|
-
access: { openToAll:
|
|
143
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
144
144
|
});
|
|
145
145
|
r.screen({
|
|
146
146
|
id: "rent-detail",
|
|
@@ -192,7 +192,7 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
|
|
|
192
192
|
test("metric navigate.tab with no screen/entity — a known section id boots cleanly", () => {
|
|
193
193
|
const feature = defineFeature("app", (r) => {
|
|
194
194
|
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
195
|
-
access: { openToAll:
|
|
195
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
196
196
|
});
|
|
197
197
|
r.screen({
|
|
198
198
|
id: "rent-detail",
|
|
@@ -214,7 +214,7 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
|
|
|
214
214
|
test("valid tabs + header + metrics declaration boots cleanly", () => {
|
|
215
215
|
const feature = defineFeature("app", (r) => {
|
|
216
216
|
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
217
|
-
access: { openToAll:
|
|
217
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
218
218
|
});
|
|
219
219
|
r.screen({
|
|
220
220
|
id: "rent-detail",
|
|
@@ -37,7 +37,7 @@ const LIST_QN = "roster:query:member:list";
|
|
|
37
37
|
const rosterFeature = defineFeature("roster", (r) => {
|
|
38
38
|
r.crud("member", memberEntity, {
|
|
39
39
|
write: { access: { roles: ["Admin"] } },
|
|
40
|
-
read: { access: { openToAll:
|
|
40
|
+
read: { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
41
41
|
});
|
|
42
42
|
r.screen({
|
|
43
43
|
id: "member-list",
|