@cosmicdrift/kumiko-framework 0.270.0 → 0.274.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/server.ts +7 -20
- package/src/bun-db/__tests__/query-guards.test.ts +60 -3
- package/src/bun-db/query.ts +24 -21
- package/src/changes.json +105 -0
- package/src/db/__tests__/build-filter-where.test.ts +8 -0
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
- package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
- package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
- package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
- package/src/db/__tests__/tenant-db-global.test.ts +92 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
- package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
- package/src/db/apply-entity-event.ts +1 -1
- package/src/db/assert-exists-in.ts +1 -1
- package/src/db/entity-field-encryption.ts +18 -0
- package/src/db/event-store-executor-context.ts +12 -5
- package/src/db/event-store-executor-read.ts +125 -37
- package/src/db/event-store-executor-write.ts +24 -18
- package/src/db/queries/shadow-swap.ts +36 -0
- package/src/db/replay-migration-sql.ts +5 -5
- package/src/db/tenant-db-runner.ts +23 -0
- package/src/db/tenant-db.ts +97 -24
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
- package/src/engine/__tests__/boot-validator.test.ts +71 -1
- package/src/engine/__tests__/engine.test.ts +86 -0
- package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
- package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +150 -0
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
- package/src/engine/__tests__/steps-read.test.ts +204 -8
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/global-tenancy.ts +13 -1
- package/src/engine/boot-validator/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +68 -3
- package/src/engine/boot-validator/query-output-columns.ts +33 -0
- package/src/engine/boot-validator/screens.ts +154 -31
- package/src/engine/build-config-feature-schema.ts +7 -4
- package/src/engine/entity-handlers.ts +74 -22
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/registry-validate.ts +36 -6
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-filter-ops.ts +10 -2
- package/src/engine/screen-helpers.ts +52 -0
- package/src/engine/steps/_read-source.ts +12 -0
- package/src/engine/steps/read-find-many.ts +8 -2
- package/src/engine/steps/read-find-one.ts +11 -8
- package/src/engine/steps/unsafe-projection-delete.ts +2 -1
- package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
- package/src/engine/types/index.ts +8 -1
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
- package/src/jobs/job-runner.ts +35 -5
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
- package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
- package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
- package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
- package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
- package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
- package/src/pipeline/dispatch-query.ts +27 -8
- package/src/pipeline/dispatch-shared.ts +141 -57
- package/src/pipeline/dispatch-stream.ts +1 -3
- package/src/pipeline/dispatch-write.ts +62 -14
- package/src/pipeline/dispatcher.ts +20 -0
- package/src/pipeline/escape-hatch-report.ts +127 -0
- package/src/pipeline/index.ts +8 -0
- package/src/pipeline/member-read-only-transaction.ts +75 -0
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
- package/src/pipeline/projections-runner.ts +15 -13
- package/src/pipeline/system-identity-switch.ts +121 -9
- package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
- package/src/scripts/codemod/migrate-db-raw.ts +19 -6
- package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
- package/src/ui-types/index.ts +3 -0
- package/src/utils/__tests__/ids.test.ts +29 -1
- package/src/utils/ids.ts +10 -1
- package/src/utils/index.ts +1 -1
|
@@ -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
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// C1 — ctx.queryProjection: read projection tables by qualified name.
|
|
1
|
+
// C1/fw#2913 — ctx.queryProjection: read projection tables by qualified name.
|
|
2
2
|
// Framework-level read surface so features don't have to import projection
|
|
3
3
|
// drizzle-tables directly. Auto-filters by tenant_id when the projection
|
|
4
|
-
// table carries that column
|
|
4
|
+
// table carries that column; { unsafeAllTenants: true } opts out but needs a
|
|
5
|
+
// grant — r.systemScope() or a declared escapeHatch on the handler.
|
|
5
6
|
|
|
6
7
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
7
8
|
import { z } from "zod";
|
|
@@ -11,10 +12,12 @@ import {
|
|
|
11
12
|
text as pgText,
|
|
12
13
|
uuid as pgUuid,
|
|
13
14
|
} from "../../db/dialect";
|
|
15
|
+
import { defineUnmanagedTable } from "../../db/entity-table-meta";
|
|
14
16
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
15
17
|
import { insertOne } from "../../db/query";
|
|
16
18
|
import { buildEntityTable } from "../../db/table-builder";
|
|
17
19
|
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
20
|
+
import type { EscapeHatchUseEvent, ProjectionTable } from "../../engine/types";
|
|
18
21
|
import {
|
|
19
22
|
resetEventStore,
|
|
20
23
|
setupTestStack,
|
|
@@ -43,6 +46,19 @@ const systemScopedTable = pgTable("read_qp_widget_audit", {
|
|
|
43
46
|
label: pgText("label").notNull(),
|
|
44
47
|
});
|
|
45
48
|
|
|
49
|
+
// Plain EntityTableMeta table (no SchemaTable symbols) — exercises
|
|
50
|
+
// hasTenantColumn's meta-fallback branch instead of the drizzle-symbol path.
|
|
51
|
+
const entityMetaWidgetTable = defineUnmanagedTable({
|
|
52
|
+
tableName: "store_qp_widget_meta",
|
|
53
|
+
columns: [
|
|
54
|
+
{ name: "widget_id", pgType: "uuid", notNull: true, primaryKey: true },
|
|
55
|
+
{ name: "tenant_id", pgType: "uuid", notNull: true },
|
|
56
|
+
{ name: "label", pgType: "text", notNull: true },
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const UNSAFE_ALL_TENANTS_REASON = "fw#2913 test — cross-tenant widget sweep";
|
|
61
|
+
|
|
46
62
|
const qpFeature = defineFeature("qp", (r) => {
|
|
47
63
|
r.entity("qp-widget", widgetEntity);
|
|
48
64
|
|
|
@@ -77,6 +93,24 @@ const qpFeature = defineFeature("qp", (r) => {
|
|
|
77
93
|
},
|
|
78
94
|
});
|
|
79
95
|
|
|
96
|
+
r.projection({
|
|
97
|
+
name: "widget-count-entitymeta",
|
|
98
|
+
source: "qp-widget",
|
|
99
|
+
// @cast-boundary test-fixture — a plain EntityTableMeta lacks SchemaTable's
|
|
100
|
+
// drizzle symbols; asEntityTableMeta()'s fallback branch reads it directly.
|
|
101
|
+
table: entityMetaWidgetTable as unknown as ProjectionTable,
|
|
102
|
+
apply: {
|
|
103
|
+
"qp-widget.created": async (event, tx) => {
|
|
104
|
+
const p = event.payload as { name?: string };
|
|
105
|
+
await insertOne(tx, entityMetaWidgetTable, {
|
|
106
|
+
widgetId: event.aggregateId,
|
|
107
|
+
tenantId: event.tenantId,
|
|
108
|
+
label: p.name ?? "?",
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
80
114
|
const executor = createEventStoreExecutor(widgetTable, widgetEntity, {
|
|
81
115
|
entityName: "qp-widget",
|
|
82
116
|
});
|
|
@@ -111,6 +145,48 @@ const qpFeature = defineFeature("qp", (r) => {
|
|
|
111
145
|
async (_query, ctx) => ctx.queryProjection("qp:projection:does-not-exist"),
|
|
112
146
|
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
113
147
|
);
|
|
148
|
+
|
|
149
|
+
r.queryHandler(
|
|
150
|
+
"widget:list-tenant-escape-hatch",
|
|
151
|
+
z.object({}),
|
|
152
|
+
async (_query, ctx) =>
|
|
153
|
+
ctx.queryProjection("qp:projection:widget-count-tenant", { unsafeAllTenants: true }),
|
|
154
|
+
{
|
|
155
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
156
|
+
escapeHatch: { reason: UNSAFE_ALL_TENANTS_REASON },
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
r.queryHandler(
|
|
161
|
+
"widget:list-entitymeta",
|
|
162
|
+
z.object({}),
|
|
163
|
+
async (_query, ctx) => ctx.queryProjection("qp:projection:widget-count-entitymeta"),
|
|
164
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
r.queryHandler(
|
|
168
|
+
"widget:list-tenant-untyped-payload",
|
|
169
|
+
z.object({ unsafeAllTenants: z.unknown() }),
|
|
170
|
+
async (query, ctx) =>
|
|
171
|
+
ctx.queryProjection("qp:projection:widget-count-tenant", {
|
|
172
|
+
// @cast-boundary test-fixture — deliberately not the literal boolean `true`,
|
|
173
|
+
// proving a truthy-but-not-strictly-true value still tenant-filters.
|
|
174
|
+
unsafeAllTenants: query.payload.unsafeAllTenants as boolean,
|
|
175
|
+
}),
|
|
176
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const qpSystemFeature = defineFeature("qp-system", (r) => {
|
|
181
|
+
r.systemScope();
|
|
182
|
+
|
|
183
|
+
r.queryHandler(
|
|
184
|
+
"widget:list-tenant-systemscope",
|
|
185
|
+
z.object({}),
|
|
186
|
+
async (_query, ctx) =>
|
|
187
|
+
ctx.queryProjection("qp:projection:widget-count-tenant", { unsafeAllTenants: true }),
|
|
188
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
189
|
+
);
|
|
114
190
|
});
|
|
115
191
|
|
|
116
192
|
let stack: TestStack;
|
|
@@ -120,8 +196,25 @@ const otherTenantAdmin = {
|
|
|
120
196
|
tenantId: "00000000-0000-4000-8000-0000000000b0" as const,
|
|
121
197
|
};
|
|
122
198
|
|
|
199
|
+
function errorReason(details: unknown): string | undefined {
|
|
200
|
+
if (!details || typeof details !== "object" || !("reason" in details)) return undefined;
|
|
201
|
+
return typeof details.reason === "string" ? details.reason : undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function recordingSink(events: EscapeHatchUseEvent[]) {
|
|
205
|
+
return async (event: EscapeHatchUseEvent) => {
|
|
206
|
+
events.push(event);
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const escapeHatchEvents: EscapeHatchUseEvent[] = [];
|
|
211
|
+
|
|
123
212
|
beforeAll(async () => {
|
|
124
|
-
stack = await setupTestStack({
|
|
213
|
+
stack = await setupTestStack({
|
|
214
|
+
features: [qpFeature, qpSystemFeature],
|
|
215
|
+
systemHooks: [],
|
|
216
|
+
extraContext: { _escapeHatchAuditSink: recordingSink(escapeHatchEvents) },
|
|
217
|
+
});
|
|
125
218
|
await unsafeCreateEntityTable(stack.db, widgetEntity, "qp-widget");
|
|
126
219
|
});
|
|
127
220
|
|
|
@@ -130,10 +223,12 @@ afterAll(async () => {
|
|
|
130
223
|
});
|
|
131
224
|
|
|
132
225
|
afterEach(async () => {
|
|
226
|
+
escapeHatchEvents.length = 0;
|
|
133
227
|
await resetEventStore(stack, [
|
|
134
228
|
"read_qp_widgets",
|
|
135
229
|
"read_qp_widget_count_tenant",
|
|
136
230
|
"read_qp_widget_audit",
|
|
231
|
+
"store_qp_widget_meta",
|
|
137
232
|
]);
|
|
138
233
|
});
|
|
139
234
|
|
|
@@ -173,24 +268,18 @@ describe("ctx.queryProjection", () => {
|
|
|
173
268
|
expect(rows.map((r) => r.label).sort()).toEqual(["X", "Y"]);
|
|
174
269
|
});
|
|
175
270
|
|
|
176
|
-
test("unsafeAllTenants=true
|
|
177
|
-
// Repurpose list-system by passing unsafeAllTenants=true — but list-system is
|
|
178
|
-
// already no-tenant-column. The semantic matters when a projection HAS
|
|
179
|
-
// tenant_id but the handler wants a cross-tenant sweep (audit). We
|
|
180
|
-
// exercise that contract via a direct queryProjection call here.
|
|
271
|
+
test("unsafeAllTenants=true without a grant is denied — no cross-tenant read", async () => {
|
|
181
272
|
await stack.http.writeOk("qp:write:widget:create", { name: "AA" }, admin);
|
|
182
273
|
await stack.http.writeOk("qp:write:widget:create", { name: "BB" }, otherTenantAdmin);
|
|
183
274
|
|
|
184
|
-
|
|
185
|
-
// helper path via a one-shot handler:
|
|
186
|
-
// (We could add an "override" handler instead, but keeping the feature
|
|
187
|
-
// surface small — assert against the two query handlers we have.)
|
|
188
|
-
const sys = await stack.http.queryOk<Array<{ label: string }>>(
|
|
275
|
+
const err = await stack.http.queryErr(
|
|
189
276
|
"qp:query:widget:list-system",
|
|
190
277
|
{ unsafeAllTenants: true },
|
|
191
278
|
admin,
|
|
192
279
|
);
|
|
193
|
-
expect(
|
|
280
|
+
expect(err.httpStatus).toBe(403);
|
|
281
|
+
expect(err.code).toBe("access_denied");
|
|
282
|
+
expect(errorReason(err.details)).toBe("unsafe_all_tenants_denied");
|
|
194
283
|
});
|
|
195
284
|
|
|
196
285
|
test("unknown projection name throws with a helpful error", async () => {
|
|
@@ -199,4 +288,70 @@ describe("ctx.queryProjection", () => {
|
|
|
199
288
|
const body = (await res.json()) as { error?: { message?: string } };
|
|
200
289
|
expect(body.error?.message).toMatch(/projection not registered|does-not-exist/);
|
|
201
290
|
});
|
|
291
|
+
|
|
292
|
+
test("escapeHatch grant lets unsafeAllTenants=true read cross-tenant, reporting exactly one unsafe-all-tenants event", async () => {
|
|
293
|
+
await stack.http.writeOk("qp:write:widget:create", { name: "AA" }, admin);
|
|
294
|
+
await stack.http.writeOk("qp:write:widget:create", { name: "BB" }, otherTenantAdmin);
|
|
295
|
+
|
|
296
|
+
const rows = await stack.http.queryOk<Array<{ label: string }>>(
|
|
297
|
+
"qp:query:widget:list-tenant-escape-hatch",
|
|
298
|
+
{},
|
|
299
|
+
admin,
|
|
300
|
+
);
|
|
301
|
+
expect(rows.map((r) => r.label).sort()).toEqual(["AA", "BB"]);
|
|
302
|
+
|
|
303
|
+
const matches = escapeHatchEvents.filter((e) => e.kind === "unsafe-all-tenants");
|
|
304
|
+
expect(matches).toEqual([
|
|
305
|
+
{
|
|
306
|
+
handler: "qp:query:widget:list-tenant-escape-hatch",
|
|
307
|
+
kind: "unsafe-all-tenants",
|
|
308
|
+
reason: UNSAFE_ALL_TENANTS_REASON,
|
|
309
|
+
tenantId: admin.tenantId,
|
|
310
|
+
actor: admin.id,
|
|
311
|
+
},
|
|
312
|
+
]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("r.systemScope() grants unsafeAllTenants=true without a declared escapeHatch", async () => {
|
|
316
|
+
await stack.http.writeOk("qp:write:widget:create", { name: "CC" }, admin);
|
|
317
|
+
await stack.http.writeOk("qp:write:widget:create", { name: "DD" }, otherTenantAdmin);
|
|
318
|
+
|
|
319
|
+
const rows = await stack.http.queryOk<Array<{ label: string }>>(
|
|
320
|
+
"qp-system:query:widget:list-tenant-systemscope",
|
|
321
|
+
{},
|
|
322
|
+
admin,
|
|
323
|
+
);
|
|
324
|
+
expect(rows.map((r) => r.label).sort()).toEqual(["CC", "DD"]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("a plain EntityTableMeta projection table is tenant-filtered without unsafeAllTenants", async () => {
|
|
328
|
+
await stack.http.writeOk("qp:write:widget:create", { name: "EE" }, admin);
|
|
329
|
+
await stack.http.writeOk("qp:write:widget:create", { name: "FF" }, otherTenantAdmin);
|
|
330
|
+
|
|
331
|
+
const forAdmin = await stack.http.queryOk<Array<{ label: string }>>(
|
|
332
|
+
"qp:query:widget:list-entitymeta",
|
|
333
|
+
{},
|
|
334
|
+
admin,
|
|
335
|
+
);
|
|
336
|
+
expect(forAdmin.map((r) => r.label)).toEqual(["EE"]);
|
|
337
|
+
|
|
338
|
+
const forOther = await stack.http.queryOk<Array<{ label: string }>>(
|
|
339
|
+
"qp:query:widget:list-entitymeta",
|
|
340
|
+
{},
|
|
341
|
+
otherTenantAdmin,
|
|
342
|
+
);
|
|
343
|
+
expect(forOther.map((r) => r.label)).toEqual(["FF"]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("a truthy-but-not-true unsafeAllTenants payload still tenant-filters (no bypass, no grant)", async () => {
|
|
347
|
+
await stack.http.writeOk("qp:write:widget:create", { name: "GG" }, admin);
|
|
348
|
+
await stack.http.writeOk("qp:write:widget:create", { name: "HH" }, otherTenantAdmin);
|
|
349
|
+
|
|
350
|
+
const rows = await stack.http.queryOk<Array<{ label: string }>>(
|
|
351
|
+
"qp:query:widget:list-tenant-untyped-payload",
|
|
352
|
+
{ unsafeAllTenants: "yes" },
|
|
353
|
+
admin,
|
|
354
|
+
);
|
|
355
|
+
expect(rows.map((r) => r.label)).toEqual(["GG"]);
|
|
356
|
+
});
|
|
202
357
|
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// fw#2861 — systemScope handlers get a default per-tenant rate limit unless
|
|
2
|
+
// they declare their own or opt out. Full-stack proof via setupTestStack +
|
|
3
|
+
// real HTTP (stack.http.write) — never createTestDispatcher. Modelled on
|
|
4
|
+
// rate-limit/__tests__/dispatcher-l3.integration.test.ts.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { createSystemUser, defineFeature } from "../../engine";
|
|
9
|
+
import type { RateLimitConfig, RateLimitDecision, RateLimitResolver } from "../../rate-limit";
|
|
10
|
+
import { createTestUser, setupTestStack, type TestStack, testTenantId } from "../../stack";
|
|
11
|
+
|
|
12
|
+
function fakeResolver(): {
|
|
13
|
+
readonly resolver: RateLimitResolver;
|
|
14
|
+
readonly calls: Array<{ bucket: string; config: RateLimitConfig }>;
|
|
15
|
+
} {
|
|
16
|
+
const calls: Array<{ bucket: string; config: RateLimitConfig }> = [];
|
|
17
|
+
const decision = (config: RateLimitConfig): RateLimitDecision => ({
|
|
18
|
+
allowed: true,
|
|
19
|
+
limit: config.limit,
|
|
20
|
+
remaining: config.limit - 1,
|
|
21
|
+
retryAfterSeconds: 0,
|
|
22
|
+
windowSeconds: config.windowSeconds,
|
|
23
|
+
// @cast-boundary test-fixture — resetAt is never read by the assertions below
|
|
24
|
+
resetAt: undefined as unknown as RateLimitDecision["resetAt"],
|
|
25
|
+
});
|
|
26
|
+
const resolver: RateLimitResolver = {
|
|
27
|
+
check: async (bucket, config) => {
|
|
28
|
+
calls.push({ bucket, config });
|
|
29
|
+
return decision(config);
|
|
30
|
+
},
|
|
31
|
+
enforce: async (bucket, config) => {
|
|
32
|
+
calls.push({ bucket, config });
|
|
33
|
+
return decision(config);
|
|
34
|
+
},
|
|
35
|
+
peek: async (bucket, config) => {
|
|
36
|
+
calls.push({ bucket, config: { ...config } });
|
|
37
|
+
return decision({ ...config, cost: undefined });
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
return { resolver, calls };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rlDefaultFeature = defineFeature("rl-default", (r) => {
|
|
44
|
+
r.systemScope();
|
|
45
|
+
|
|
46
|
+
r.writeHandler("explicit", z.object({}), async () => ({ isSuccess: true as const, data: {} }), {
|
|
47
|
+
access: { roles: ["Admin"] },
|
|
48
|
+
rateLimit: { per: "tenant", limit: 2, windowSeconds: 60 },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
r.writeHandler(
|
|
52
|
+
"implicit-default",
|
|
53
|
+
z.object({}),
|
|
54
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
55
|
+
{
|
|
56
|
+
access: { roles: ["Admin"] },
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
r.writeHandler(
|
|
61
|
+
"as-system-probe",
|
|
62
|
+
z.object({}),
|
|
63
|
+
async (event, ctx) => {
|
|
64
|
+
const sysUser = createSystemUser(event.user.tenantId);
|
|
65
|
+
const res = await ctx.writeAs(sysUser, "rl-default:write:implicit-default", {});
|
|
66
|
+
return { isSuccess: true as const, data: res };
|
|
67
|
+
},
|
|
68
|
+
{ access: { roles: ["Admin"] } },
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let stack: TestStack;
|
|
73
|
+
let calls: Array<{ bucket: string; config: RateLimitConfig }>;
|
|
74
|
+
const TENANT = testTenantId(9201);
|
|
75
|
+
const admin = createTestUser({ tenantId: TENANT, roles: ["Admin"] });
|
|
76
|
+
|
|
77
|
+
beforeAll(async () => {
|
|
78
|
+
const { resolver, calls: recorded } = fakeResolver();
|
|
79
|
+
calls = recorded;
|
|
80
|
+
stack = await setupTestStack({
|
|
81
|
+
features: [rlDefaultFeature],
|
|
82
|
+
extraContext: { rateLimit: resolver },
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterAll(async () => {
|
|
87
|
+
await stack.cleanup();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("systemScope default rate limit (fw#2861)", () => {
|
|
91
|
+
test("explicit rateLimit still wins over the systemScope default", async () => {
|
|
92
|
+
calls.length = 0;
|
|
93
|
+
const res = await stack.http.write("rl-default:write:explicit", {}, admin);
|
|
94
|
+
expect(res.status).toBe(200);
|
|
95
|
+
expect(calls).toHaveLength(1);
|
|
96
|
+
expect(calls[0]?.bucket).toBe(`tenant:${TENANT}`);
|
|
97
|
+
expect(calls[0]?.config).toEqual({ limit: 2, windowSeconds: 60, cost: undefined });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("no declared rateLimit on a systemScope handler -> the default {per:tenant+handler,limit:600,windowSeconds:60} fires", async () => {
|
|
101
|
+
calls.length = 0;
|
|
102
|
+
const res = await stack.http.write("rl-default:write:implicit-default", {}, admin);
|
|
103
|
+
expect(res.status).toBe(200);
|
|
104
|
+
expect(calls).toHaveLength(1);
|
|
105
|
+
expect(calls[0]?.bucket).toBe(`tenant+handler:${TENANT}:rl-default:write:implicit-default`);
|
|
106
|
+
expect(calls[0]?.config).toEqual({ limit: 600, windowSeconds: 60, cost: undefined });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a SYSTEM-identity caller (ctx.writeAs) never touches the resolver", async () => {
|
|
110
|
+
calls.length = 0;
|
|
111
|
+
const res = await stack.http.write("rl-default:write:as-system-probe", {}, admin);
|
|
112
|
+
expect(res.status).toBe(200);
|
|
113
|
+
// One call for the probe handler's own dispatch (bucket = admin's tenant+handler),
|
|
114
|
+
// NONE for the inner SYSTEM-identity writeAs to implicit-default.
|
|
115
|
+
expect(calls).toHaveLength(1);
|
|
116
|
+
expect(calls[0]?.bucket).toBe(`tenant+handler:${TENANT}:rl-default:write:as-system-probe`);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Temporal } from "temporal-polyfill";
|
|
3
|
+
import { createSystemUser } from "../../engine";
|
|
4
|
+
import type { RateLimitConfig, RateLimitDecision, RateLimitResolver } from "../../rate-limit";
|
|
5
|
+
import { createTestUser, testTenantId } from "../../stack";
|
|
6
|
+
import type { DispatchContext } from "../dispatch-shared";
|
|
7
|
+
import { enforceRateLimit } from "../dispatch-shared";
|
|
8
|
+
|
|
9
|
+
const TENANT = testTenantId(9001);
|
|
10
|
+
|
|
11
|
+
function fakeResolver(): {
|
|
12
|
+
readonly resolver: RateLimitResolver;
|
|
13
|
+
readonly calls: Array<{ bucket: string; config: RateLimitConfig }>;
|
|
14
|
+
} {
|
|
15
|
+
const calls: Array<{ bucket: string; config: RateLimitConfig }> = [];
|
|
16
|
+
const decision: RateLimitDecision = {
|
|
17
|
+
allowed: true,
|
|
18
|
+
limit: 0,
|
|
19
|
+
remaining: 0,
|
|
20
|
+
retryAfterSeconds: 0,
|
|
21
|
+
windowSeconds: 0,
|
|
22
|
+
resetAt: {} as Temporal.Instant,
|
|
23
|
+
};
|
|
24
|
+
const resolver: RateLimitResolver = {
|
|
25
|
+
check: async (bucket, config) => {
|
|
26
|
+
calls.push({ bucket, config });
|
|
27
|
+
return { ...decision, limit: config.limit, windowSeconds: config.windowSeconds };
|
|
28
|
+
},
|
|
29
|
+
enforce: async (bucket, config) => {
|
|
30
|
+
calls.push({ bucket, config });
|
|
31
|
+
return { ...decision, limit: config.limit, windowSeconds: config.windowSeconds };
|
|
32
|
+
},
|
|
33
|
+
peek: async (bucket, config) => {
|
|
34
|
+
calls.push({ bucket, config: { ...config } });
|
|
35
|
+
return { ...decision, limit: config.limit, windowSeconds: config.windowSeconds };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
return { resolver, calls };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fakeCtx(rateLimit: RateLimitResolver | undefined): DispatchContext {
|
|
42
|
+
return {
|
|
43
|
+
appContext: { rateLimit },
|
|
44
|
+
} as unknown as DispatchContext; // @cast-boundary test-fixture — enforceRateLimit only reads ctx.appContext
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("enforceRateLimit — systemScope default", () => {
|
|
48
|
+
test("systemScope handler, no declared rateLimit, resolver configured, regular user -> enforces the default per-tenant+handler limit", async () => {
|
|
49
|
+
const { resolver, calls } = fakeResolver();
|
|
50
|
+
const user = createTestUser({ tenantId: TENANT });
|
|
51
|
+
|
|
52
|
+
await enforceRateLimit(fakeCtx(resolver), undefined, "some:write:handler", user, true);
|
|
53
|
+
|
|
54
|
+
expect(calls).toHaveLength(1);
|
|
55
|
+
expect(calls[0]?.bucket).toBe(`tenant+handler:${TENANT}:some:write:handler`);
|
|
56
|
+
expect(calls[0]?.config).toEqual({ limit: 600, windowSeconds: 60, cost: undefined });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("systemScope handler, SYSTEM-identity caller -> never touches the resolver", async () => {
|
|
60
|
+
const { resolver, calls } = fakeResolver();
|
|
61
|
+
const systemUser = createSystemUser(TENANT);
|
|
62
|
+
|
|
63
|
+
await enforceRateLimit(fakeCtx(resolver), undefined, "some:write:handler", systemUser, true);
|
|
64
|
+
|
|
65
|
+
expect(calls).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("systemScope handler, no resolver configured -> no-op, no throw", async () => {
|
|
69
|
+
const user = createTestUser({ tenantId: TENANT });
|
|
70
|
+
|
|
71
|
+
await expect(
|
|
72
|
+
enforceRateLimit(fakeCtx(undefined), undefined, "some:write:handler", user, true),
|
|
73
|
+
).resolves.toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("systemScope handler with an explicit rateLimit -> that option is enforced, not the default", async () => {
|
|
77
|
+
const { resolver, calls } = fakeResolver();
|
|
78
|
+
const user = createTestUser({ tenantId: TENANT });
|
|
79
|
+
|
|
80
|
+
await enforceRateLimit(
|
|
81
|
+
fakeCtx(resolver),
|
|
82
|
+
{ per: "user", limit: 3, windowSeconds: 10 },
|
|
83
|
+
"some:write:handler",
|
|
84
|
+
user,
|
|
85
|
+
true,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(calls).toHaveLength(1);
|
|
89
|
+
expect(calls[0]?.bucket).toBe(`user:${user.id}`);
|
|
90
|
+
expect(calls[0]?.config).toEqual({ limit: 3, windowSeconds: 10, cost: undefined });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("systemScope handler with rateLimit: { disabled: true, reason } -> never enforced, no error", async () => {
|
|
94
|
+
const { resolver, calls } = fakeResolver();
|
|
95
|
+
const user = createTestUser({ tenantId: TENANT });
|
|
96
|
+
|
|
97
|
+
await expect(
|
|
98
|
+
enforceRateLimit(
|
|
99
|
+
fakeCtx(resolver),
|
|
100
|
+
{ disabled: true, reason: "ops replay path" },
|
|
101
|
+
"some:write:handler",
|
|
102
|
+
user,
|
|
103
|
+
true,
|
|
104
|
+
),
|
|
105
|
+
).resolves.toBeUndefined();
|
|
106
|
+
expect(calls).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("non-systemScope handler, no declared rateLimit -> never enforced (unchanged baseline behavior)", async () => {
|
|
110
|
+
const { resolver, calls } = fakeResolver();
|
|
111
|
+
const user = createTestUser({ tenantId: TENANT });
|
|
112
|
+
|
|
113
|
+
await enforceRateLimit(fakeCtx(resolver), undefined, "some:write:handler", user, false);
|
|
114
|
+
|
|
115
|
+
expect(calls).toHaveLength(0);
|
|
116
|
+
});
|
|
117
|
+
});
|