@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
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
10
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
11
11
|
import { asRawClient } from "../../db/query";
|
|
12
|
-
import { createEntity, createTextField } from "../../engine";
|
|
12
|
+
import { createEntity, createTextField, from } from "../../engine";
|
|
13
13
|
import type { EntityDefinition } from "../../engine/types";
|
|
14
14
|
import { createEventsTable } from "../../event-store";
|
|
15
15
|
import { createInMemorySearchAdapter } from "../../search";
|
|
@@ -70,6 +70,45 @@ const tenantMetaEntity = createEntity({
|
|
|
70
70
|
});
|
|
71
71
|
const tenantMetaTable = buildEntityTable("tenant", tenantMetaEntity);
|
|
72
72
|
|
|
73
|
+
// PII target with a derived search index — ciphertext at rest, plaintext
|
|
74
|
+
// mirrored into the adapter; `ownerId` + `access.read` gives a real row-owned rule.
|
|
75
|
+
const piiCustomerEntity = createEntity({
|
|
76
|
+
table: "read_ref_search_pii_customers",
|
|
77
|
+
fields: {
|
|
78
|
+
ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
79
|
+
name: createTextField({ required: true, personal: "self", find: "fuzzy" }),
|
|
80
|
+
},
|
|
81
|
+
access: {
|
|
82
|
+
read: { Admin: "all", User: from("user:id", "ownerId") },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const piiCustomerTable = buildEntityTable("refSearchPiiCustomer", piiCustomerEntity);
|
|
86
|
+
|
|
87
|
+
const piiOrderEntity = createEntity({
|
|
88
|
+
table: "read_ref_search_pii_orders",
|
|
89
|
+
fields: {
|
|
90
|
+
note: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
|
|
91
|
+
customerId: {
|
|
92
|
+
type: "reference",
|
|
93
|
+
entity: "refSearchPiiCustomer",
|
|
94
|
+
labelField: "name",
|
|
95
|
+
searchable: true,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const piiOrderTable = buildEntityTable("refSearchPiiOrder", piiOrderEntity);
|
|
100
|
+
|
|
101
|
+
function resolvePiiEntity(name: string): EntityDefinition | undefined {
|
|
102
|
+
return name === "refSearchPiiCustomer" ? piiCustomerEntity : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const piiReferenceSearch = {
|
|
106
|
+
fields: [
|
|
107
|
+
{ fieldName: "customerId", targetEntityName: "refSearchPiiCustomer", labelField: "name" },
|
|
108
|
+
],
|
|
109
|
+
resolveEntity: resolvePiiEntity,
|
|
110
|
+
};
|
|
111
|
+
|
|
73
112
|
function resolveEntity(name: string): EntityDefinition | undefined {
|
|
74
113
|
return name === "refSearchCustomer" ? customerEntity : undefined;
|
|
75
114
|
}
|
|
@@ -104,6 +143,9 @@ const orderExec = createEventStoreExecutor(orderTable, orderEntity, {
|
|
|
104
143
|
const restrictedOrderExec = createEventStoreExecutor(restrictedOrderTable, restrictedOrderEntity, {
|
|
105
144
|
entityName: "refSearchRestrictedOrder",
|
|
106
145
|
});
|
|
146
|
+
const piiOrderExec = createEventStoreExecutor(piiOrderTable, piiOrderEntity, {
|
|
147
|
+
entityName: "refSearchPiiOrder",
|
|
148
|
+
});
|
|
107
149
|
|
|
108
150
|
beforeAll(async () => {
|
|
109
151
|
await ensureTemporalPolyfill();
|
|
@@ -111,6 +153,8 @@ beforeAll(async () => {
|
|
|
111
153
|
await unsafeCreateEntityTable(testDb.db, customerEntity, "refSearchCustomer");
|
|
112
154
|
await unsafeCreateEntityTable(testDb.db, orderEntity, "refSearchOrder");
|
|
113
155
|
await unsafeCreateEntityTable(testDb.db, restrictedOrderEntity, "refSearchRestrictedOrder");
|
|
156
|
+
await unsafeCreateEntityTable(testDb.db, piiCustomerEntity, "refSearchPiiCustomer");
|
|
157
|
+
await unsafeCreateEntityTable(testDb.db, piiOrderEntity, "refSearchPiiOrder");
|
|
114
158
|
await unsafeCreateEntityTable(testDb.db, tenantMetaEntity, "tenant");
|
|
115
159
|
await createEventsTable(testDb.db);
|
|
116
160
|
tdbA = createTenantDb(testDb.db, admin.tenantId);
|
|
@@ -124,7 +168,8 @@ afterAll(async () => {
|
|
|
124
168
|
beforeEach(async () => {
|
|
125
169
|
await asRawClient(testDb.db).unsafe(
|
|
126
170
|
"TRUNCATE kumiko_events, read_ref_search_customers, read_ref_search_orders, " +
|
|
127
|
-
"read_ref_search_restricted_orders,
|
|
171
|
+
"read_ref_search_restricted_orders, read_ref_search_pii_customers, " +
|
|
172
|
+
"read_ref_search_pii_orders, read_tenants RESTART IDENTITY CASCADE",
|
|
128
173
|
);
|
|
129
174
|
});
|
|
130
175
|
|
|
@@ -379,3 +424,222 @@ describe("event-store-executor.list — searchable reference fields (fw#2660)",
|
|
|
379
424
|
}
|
|
380
425
|
});
|
|
381
426
|
});
|
|
427
|
+
|
|
428
|
+
describe("event-store-executor.list — searchable reference to an encrypted/PII labelField via the search index", () => {
|
|
429
|
+
test('matches via the target\'s derived search index when the labelField is PII (find: "fuzzy")', async () => {
|
|
430
|
+
const [contact] = await seedRows(testDb.db, piiCustomerTable, [
|
|
431
|
+
{
|
|
432
|
+
id: crypto.randomUUID(),
|
|
433
|
+
tenantId: admin.tenantId,
|
|
434
|
+
ownerId: admin.id,
|
|
435
|
+
name: "Confidential Contact",
|
|
436
|
+
},
|
|
437
|
+
]);
|
|
438
|
+
const [order] = await seedRows(testDb.db, piiOrderTable, [
|
|
439
|
+
{
|
|
440
|
+
id: crypto.randomUUID(),
|
|
441
|
+
tenantId: admin.tenantId,
|
|
442
|
+
note: "totally unrelated",
|
|
443
|
+
customerId: (contact as { id: string }).id,
|
|
444
|
+
},
|
|
445
|
+
]);
|
|
446
|
+
|
|
447
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
448
|
+
// One shared per-tenant index across every entity type (real Meilisearch
|
|
449
|
+
// adapter parity: one index per tenant, `_type` is only a filter
|
|
450
|
+
// attribute) — `searchableFields` must list every entity's own
|
|
451
|
+
// searchable field, not just this test's own entity's.
|
|
452
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
|
|
453
|
+
// Plaintext label lives in the search adapter under the target's own
|
|
454
|
+
// type — never a raw ILIKE scan of the (encrypted-at-rest) column.
|
|
455
|
+
await searchAdapter.index(admin.tenantId, {
|
|
456
|
+
entityType: "refSearchPiiCustomer",
|
|
457
|
+
entityId: (contact as { id: string }).id,
|
|
458
|
+
weight: 1,
|
|
459
|
+
fields: { name: "Confidential Contact" },
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const res = await piiOrderExec.list({ search: "confidential" }, admin, tdbA, {
|
|
463
|
+
searchAdapter,
|
|
464
|
+
referenceSearch: piiReferenceSearch,
|
|
465
|
+
});
|
|
466
|
+
expect(res.rows.map((r) => r["id"])).toEqual([(order as { id: string }).id]);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("drops the reference clause above 200 search-index matches — native search still applies", async () => {
|
|
470
|
+
const fooCustomers = Array.from({ length: 201 }, (_, i) => ({
|
|
471
|
+
id: crypto.randomUUID(),
|
|
472
|
+
tenantId: admin.tenantId,
|
|
473
|
+
ownerId: admin.id,
|
|
474
|
+
name: `Foo Contact ${i}`,
|
|
475
|
+
}));
|
|
476
|
+
await seedRows(testDb.db, piiCustomerTable, fooCustomers);
|
|
477
|
+
|
|
478
|
+
const [refOnlyOrder, textMatchOrder] = await seedRows(testDb.db, piiOrderTable, [
|
|
479
|
+
{
|
|
480
|
+
id: crypto.randomUUID(),
|
|
481
|
+
tenantId: admin.tenantId,
|
|
482
|
+
note: "no keyword here",
|
|
483
|
+
customerId: fooCustomers[5]?.id,
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
id: crypto.randomUUID(),
|
|
487
|
+
tenantId: admin.tenantId,
|
|
488
|
+
note: "foo appears right here",
|
|
489
|
+
customerId: fooCustomers[10]?.id,
|
|
490
|
+
},
|
|
491
|
+
]);
|
|
492
|
+
|
|
493
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
494
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
|
|
495
|
+
for (const c of fooCustomers) {
|
|
496
|
+
await searchAdapter.index(admin.tenantId, {
|
|
497
|
+
entityType: "refSearchPiiCustomer",
|
|
498
|
+
entityId: c.id,
|
|
499
|
+
weight: 1,
|
|
500
|
+
fields: { name: c.name },
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
for (const o of [refOnlyOrder, textMatchOrder]) {
|
|
504
|
+
const row = o as { id: string; note: string };
|
|
505
|
+
await searchAdapter.index(admin.tenantId, {
|
|
506
|
+
entityType: "refSearchPiiOrder",
|
|
507
|
+
entityId: row.id,
|
|
508
|
+
weight: 1,
|
|
509
|
+
fields: { note: row.note },
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const res = await piiOrderExec.list({ search: "foo" }, admin, tdbA, {
|
|
514
|
+
searchAdapter,
|
|
515
|
+
referenceSearch: piiReferenceSearch,
|
|
516
|
+
});
|
|
517
|
+
expect(res.rows.map((r) => r["id"])).toEqual([(textMatchOrder as { id: string }).id]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("a target label match in a foreign tenant's search index never surfaces a row (mandatory)", async () => {
|
|
521
|
+
const [contact] = await seedRows(testDb.db, piiCustomerTable, [
|
|
522
|
+
{
|
|
523
|
+
id: crypto.randomUUID(),
|
|
524
|
+
tenantId: otherTenantAdmin.tenantId,
|
|
525
|
+
ownerId: otherTenantAdmin.id,
|
|
526
|
+
name: "Foreign Contact",
|
|
527
|
+
},
|
|
528
|
+
]);
|
|
529
|
+
await seedRows(testDb.db, piiOrderTable, [
|
|
530
|
+
{
|
|
531
|
+
id: crypto.randomUUID(),
|
|
532
|
+
tenantId: otherTenantAdmin.tenantId,
|
|
533
|
+
note: "unrelated",
|
|
534
|
+
customerId: (contact as { id: string }).id,
|
|
535
|
+
},
|
|
536
|
+
]);
|
|
537
|
+
|
|
538
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
539
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
|
|
540
|
+
// Indexed only under the OTHER tenant's search index — admin's own
|
|
541
|
+
// search must never see it, regardless of the FK row's actual tenantId.
|
|
542
|
+
await searchAdapter.index(otherTenantAdmin.tenantId, {
|
|
543
|
+
entityType: "refSearchPiiCustomer",
|
|
544
|
+
entityId: (contact as { id: string }).id,
|
|
545
|
+
weight: 1,
|
|
546
|
+
fields: { name: "Foreign Contact" },
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const res = await piiOrderExec.list({ search: "foreign" }, admin, tdbA, {
|
|
550
|
+
searchAdapter,
|
|
551
|
+
referenceSearch: piiReferenceSearch,
|
|
552
|
+
});
|
|
553
|
+
expect(res.rows).toHaveLength(0);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("row-level ownership on the target entity intersects the search-index candidates, same as the target's own list would", async () => {
|
|
557
|
+
const [ownRow, otherRow] = await seedRows(testDb.db, piiCustomerTable, [
|
|
558
|
+
{
|
|
559
|
+
id: crypto.randomUUID(),
|
|
560
|
+
tenantId: admin.tenantId,
|
|
561
|
+
ownerId: TestUsers.user.id,
|
|
562
|
+
name: "Shared Label",
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
id: crypto.randomUUID(),
|
|
566
|
+
tenantId: admin.tenantId,
|
|
567
|
+
ownerId: admin.id,
|
|
568
|
+
name: "Shared Label",
|
|
569
|
+
},
|
|
570
|
+
]);
|
|
571
|
+
const [ownOrder, otherOrder] = await seedRows(testDb.db, piiOrderTable, [
|
|
572
|
+
{
|
|
573
|
+
id: crypto.randomUUID(),
|
|
574
|
+
tenantId: admin.tenantId,
|
|
575
|
+
note: "n1",
|
|
576
|
+
customerId: (ownRow as { id: string }).id,
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
id: crypto.randomUUID(),
|
|
580
|
+
tenantId: admin.tenantId,
|
|
581
|
+
note: "n2",
|
|
582
|
+
customerId: (otherRow as { id: string }).id,
|
|
583
|
+
},
|
|
584
|
+
]);
|
|
585
|
+
|
|
586
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
587
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
|
|
588
|
+
for (const row of [ownRow, otherRow]) {
|
|
589
|
+
const r = row as { id: string; name: string };
|
|
590
|
+
await searchAdapter.index(admin.tenantId, {
|
|
591
|
+
entityType: "refSearchPiiCustomer",
|
|
592
|
+
entityId: r.id,
|
|
593
|
+
weight: 1,
|
|
594
|
+
fields: { name: r.name },
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Both customers match "shared" in the index — the search index itself
|
|
599
|
+
// has no ownership awareness. Only the order pointing at the customer
|
|
600
|
+
// TestUsers.user (role "User") actually owns via ownerId may surface.
|
|
601
|
+
const res = await piiOrderExec.list({ search: "shared" }, TestUsers.user, tdbA, {
|
|
602
|
+
searchAdapter,
|
|
603
|
+
referenceSearch: piiReferenceSearch,
|
|
604
|
+
});
|
|
605
|
+
expect(res.rows.map((r) => r["id"])).toEqual([(ownOrder as { id: string }).id]);
|
|
606
|
+
expect(res.rows.map((r) => r["id"])).not.toContain((otherOrder as { id: string }).id);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("a role with no read grant on the target entity gets no reference-match hit at all", async () => {
|
|
610
|
+
const [contact] = await seedRows(testDb.db, piiCustomerTable, [
|
|
611
|
+
{
|
|
612
|
+
id: crypto.randomUUID(),
|
|
613
|
+
tenantId: admin.tenantId,
|
|
614
|
+
ownerId: admin.id,
|
|
615
|
+
name: "Driver Blind Spot",
|
|
616
|
+
},
|
|
617
|
+
]);
|
|
618
|
+
await seedRows(testDb.db, piiOrderTable, [
|
|
619
|
+
{
|
|
620
|
+
id: crypto.randomUUID(),
|
|
621
|
+
tenantId: admin.tenantId,
|
|
622
|
+
note: "unrelated",
|
|
623
|
+
customerId: (contact as { id: string }).id,
|
|
624
|
+
},
|
|
625
|
+
]);
|
|
626
|
+
|
|
627
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
628
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
|
|
629
|
+
await searchAdapter.index(admin.tenantId, {
|
|
630
|
+
entityType: "refSearchPiiCustomer",
|
|
631
|
+
entityId: (contact as { id: string }).id,
|
|
632
|
+
weight: 1,
|
|
633
|
+
fields: { name: "Driver Blind Spot" },
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// "Driver" has no rule at all on piiCustomerEntity.access.read →
|
|
637
|
+
// buildOwnershipClause returns "empty" — no reference-match hit, no
|
|
638
|
+
// matter what the search index found.
|
|
639
|
+
const res = await piiOrderExec.list({ search: "driver" }, TestUsers.driver, tdbA, {
|
|
640
|
+
searchAdapter,
|
|
641
|
+
referenceSearch: piiReferenceSearch,
|
|
642
|
+
});
|
|
643
|
+
expect(res.rows).toHaveLength(0);
|
|
644
|
+
});
|
|
645
|
+
});
|
|
@@ -385,6 +385,83 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
|
|
|
385
385
|
});
|
|
386
386
|
});
|
|
387
387
|
|
|
388
|
+
// =============================================================================
|
|
389
|
+
// Explicit-id create: cross-tenant isolation, no resurrection of a deleted row
|
|
390
|
+
// =============================================================================
|
|
391
|
+
|
|
392
|
+
const deletedIdEntity = createEntity({
|
|
393
|
+
table: "read_es_write_deleted_id",
|
|
394
|
+
fields: {
|
|
395
|
+
email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
396
|
+
},
|
|
397
|
+
softDelete: true,
|
|
398
|
+
});
|
|
399
|
+
const deletedIdTable = buildEntityTable("esWriteDeletedId", deletedIdEntity);
|
|
400
|
+
|
|
401
|
+
describe("event-store-executor write-verbs — explicit-id tenant isolation + soft-delete", () => {
|
|
402
|
+
const crud = createEventStoreExecutor(deletedIdTable, deletedIdEntity, {
|
|
403
|
+
entityName: "esWriteDeletedId",
|
|
404
|
+
});
|
|
405
|
+
let otherTenantDb: TenantDb;
|
|
406
|
+
|
|
407
|
+
beforeAll(async () => {
|
|
408
|
+
await unsafeCreateEntityTable(testDb.db, deletedIdEntity, "esWriteDeletedId");
|
|
409
|
+
otherTenantDb = createTenantDb(testDb.db, TestUsers.otherTenant.tenantId);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
beforeEach(async () => {
|
|
413
|
+
await asRawClient(testDb.db).unsafe(
|
|
414
|
+
`TRUNCATE kumiko_events, read_es_write_deleted_id RESTART IDENTITY CASCADE`,
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("create with an id already used by another tenant → conflict, tenant A's row untouched, no leak", async () => {
|
|
419
|
+
// The event log itself is tenant-scoped (UNIQUE (tenant_id, aggregate_id,
|
|
420
|
+
// version)) and would happily let tenant B open its own stream at the
|
|
421
|
+
// same id — but every entity's projection table has a plain `id` primary
|
|
422
|
+
// key (table-builder.ts), shared across all tenants for that entity
|
|
423
|
+
// type. So a same-id create for a different tenant fails at the
|
|
424
|
+
// projection-insert step with `unique_violation`, not `version_conflict`
|
|
425
|
+
// — a different, still-typed error, and the mapped error's details carry
|
|
426
|
+
// only entityName/constraintName, nothing that identifies tenant A's row.
|
|
427
|
+
const first = await crud.create({ email: "tenant-a@test.de" }, admin, tdb);
|
|
428
|
+
if (!first.isSuccess) throw new Error("setup failed");
|
|
429
|
+
|
|
430
|
+
const other = await crud.create(
|
|
431
|
+
{ id: first.data.id, email: "tenant-b@test.de" },
|
|
432
|
+
TestUsers.otherTenant,
|
|
433
|
+
otherTenantDb,
|
|
434
|
+
);
|
|
435
|
+
expect(other.isSuccess).toBe(false);
|
|
436
|
+
if (other.isSuccess) return;
|
|
437
|
+
expect(other.error.code).toBe("unique_violation");
|
|
438
|
+
expect(JSON.stringify(other.error.details)).not.toContain(admin.tenantId);
|
|
439
|
+
|
|
440
|
+
const row = await asRawClient(testDb.db).unsafe(
|
|
441
|
+
`SELECT email FROM read_es_write_deleted_id WHERE id = $1`,
|
|
442
|
+
[first.data.id],
|
|
443
|
+
);
|
|
444
|
+
expect((row as unknown as { email: string }[])[0]?.email).toBe("tenant-a@test.de");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("create with the id of a soft-deleted row → version_conflict, no resurrection", async () => {
|
|
448
|
+
const created = await crud.create({ email: "gone@test.de" }, admin, tdb);
|
|
449
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
450
|
+
|
|
451
|
+
const deleted = await crud.delete({ id: created.data.id }, admin, tdb);
|
|
452
|
+
expect(deleted.isSuccess).toBe(true);
|
|
453
|
+
|
|
454
|
+
const resurrect = await crud.create(
|
|
455
|
+
{ id: created.data.id, email: "back-from-the-dead@test.de" },
|
|
456
|
+
admin,
|
|
457
|
+
tdb,
|
|
458
|
+
);
|
|
459
|
+
expect(resurrect.isSuccess).toBe(false);
|
|
460
|
+
if (resurrect.isSuccess) return;
|
|
461
|
+
expect(resurrect.error.code).toBe("version_conflict");
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
388
465
|
// =============================================================================
|
|
389
466
|
// Concurrent update race → EventStoreVersionConflict catch + entityCache.del
|
|
390
467
|
// on forget/restore (create/update/delete already exercise cache in the
|
|
@@ -807,7 +807,7 @@ describe("event-store-executor — pii subject encryption", () => {
|
|
|
807
807
|
0,
|
|
808
808
|
);
|
|
809
809
|
if (!storedEvent) throw new Error("no persisted event for the aggregate");
|
|
810
|
-
const applied = await applyEntityEvent(storedEvent, piiTable, piiEntity,
|
|
810
|
+
const applied = await applyEntityEvent(storedEvent, piiTable, piiEntity, testDb.db);
|
|
811
811
|
expect(applied.kind).toBe("applied");
|
|
812
812
|
|
|
813
813
|
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
@@ -365,6 +365,23 @@ ALTER TABLE "widgets" OWNER TO app_user;`,
|
|
|
365
365
|
}
|
|
366
366
|
});
|
|
367
367
|
|
|
368
|
+
test("FORCE / NO FORCE ROW LEVEL SECURITY is shape-neutral (fw#2907)", () => {
|
|
369
|
+
const dir = tmpMigrationsDir();
|
|
370
|
+
try {
|
|
371
|
+
write(
|
|
372
|
+
dir,
|
|
373
|
+
"0001_init.sql",
|
|
374
|
+
`CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid PRIMARY KEY, "title" text);
|
|
375
|
+
ALTER TABLE "widgets" FORCE ROW LEVEL SECURITY;
|
|
376
|
+
ALTER TABLE widgets NO FORCE ROW LEVEL SECURITY;`,
|
|
377
|
+
);
|
|
378
|
+
const replayed = replayMigrationsDir(dir);
|
|
379
|
+
expect([...(replayed.get("widgets")?.columns ?? [])].sort()).toEqual(["id", "title"]);
|
|
380
|
+
} finally {
|
|
381
|
+
rmSync(dir, { recursive: true, force: true });
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
368
385
|
test("unexpected table: migrations create a table the snapshot doesn't know about", () => {
|
|
369
386
|
const dir = tmpMigrationsDir();
|
|
370
387
|
try {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EscapeHatchKind } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
4
|
+
import { InternalError } from "../../errors";
|
|
5
|
+
import { testTenantId } from "../../stack";
|
|
6
|
+
import type { DbRunner } from "../connection";
|
|
7
|
+
import * as dbIndex from "../index";
|
|
8
|
+
import { acknowledgeConventionCrossTenant, createTenantDb, withUnsafeRawGrant } from "../tenant-db";
|
|
9
|
+
import { tenantDbRunner } from "../tenant-db-runner";
|
|
10
|
+
|
|
11
|
+
const tenantId = testTenantId(1);
|
|
12
|
+
const REASON = "operator scan across every tenant";
|
|
13
|
+
|
|
14
|
+
function fakeRunner(): DbRunner {
|
|
15
|
+
return {
|
|
16
|
+
unsafe: async () => [],
|
|
17
|
+
begin: async () => {
|
|
18
|
+
throw new Error("begin not used in these tests");
|
|
19
|
+
},
|
|
20
|
+
} as unknown as DbRunner;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("acknowledgeConventionCrossTenant", () => {
|
|
24
|
+
test("returns a system-mode TenantDb with the same tenantId", () => {
|
|
25
|
+
const tdb = createTenantDb(fakeRunner(), tenantId);
|
|
26
|
+
const result = acknowledgeConventionCrossTenant(tdb, REASON);
|
|
27
|
+
expect(result.mode).toBe("system");
|
|
28
|
+
expect(result.tenantId).toBe(tenantId);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("reports acknowledge-cross-tenant exactly once via grants.report", () => {
|
|
32
|
+
const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
|
|
33
|
+
const tdb = createTenantDb(fakeRunner(), tenantId, "tenant", undefined, undefined, undefined, {
|
|
34
|
+
report: (kind, reason) => {
|
|
35
|
+
reports.push({ kind, reason });
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
acknowledgeConventionCrossTenant(tdb, REASON);
|
|
39
|
+
expect(reports).toEqual([{ kind: "acknowledge-cross-tenant", reason: REASON }]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("throws for an empty reason", () => {
|
|
43
|
+
const tdb = createTenantDb(fakeRunner(), tenantId);
|
|
44
|
+
expect(() => acknowledgeConventionCrossTenant(tdb, "")).toThrow();
|
|
45
|
+
expect(() => acknowledgeConventionCrossTenant(tdb, " ")).toThrow();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("throws InternalError for a TenantDb not built by createTenantDb", () => {
|
|
49
|
+
const handBuilt = { tenantId, mode: "tenant" } as unknown as TenantDb;
|
|
50
|
+
expect(() => acknowledgeConventionCrossTenant(handBuilt, REASON)).toThrow(InternalError);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("still resolves after withUnsafeRawGrant rebinds the TenantDb", () => {
|
|
54
|
+
const runner = fakeRunner();
|
|
55
|
+
const tdb = createTenantDb(runner, tenantId);
|
|
56
|
+
const regranted = withUnsafeRawGrant(tdb, { reason: "unrelated unsafeRaw grant" });
|
|
57
|
+
const result = acknowledgeConventionCrossTenant(regranted, REASON);
|
|
58
|
+
expect(result.mode).toBe("system");
|
|
59
|
+
expect(tenantDbRunner(result)).toBe(runner);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("tenantDbRunner(result) resolves to the original runner", () => {
|
|
63
|
+
const runner = fakeRunner();
|
|
64
|
+
const tdb = createTenantDb(runner, tenantId);
|
|
65
|
+
const result = acknowledgeConventionCrossTenant(tdb, REASON);
|
|
66
|
+
expect(tenantDbRunner(result)).toBe(runner);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("db/index.ts does not export acknowledgeConventionCrossTenant", () => {
|
|
70
|
+
expect("acknowledgeConventionCrossTenant" in dbIndex).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EscapeHatchKind } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
4
|
+
import { AccessDeniedError, InternalError } from "../../errors";
|
|
5
|
+
import { testTenantId } from "../../stack";
|
|
6
|
+
import type { DbRunner } from "../connection";
|
|
7
|
+
import {
|
|
8
|
+
createTenantDb,
|
|
9
|
+
createUncheckedSystemDb,
|
|
10
|
+
unsafeRawForDeclaredStep,
|
|
11
|
+
withUnsafeRawGrant,
|
|
12
|
+
} from "../tenant-db";
|
|
13
|
+
|
|
14
|
+
const tenantId = testTenantId(1);
|
|
15
|
+
const REASON = "declared step forwarding unsafeRaw";
|
|
16
|
+
|
|
17
|
+
function fakeRunner(): DbRunner {
|
|
18
|
+
return {
|
|
19
|
+
unsafe: async () => [],
|
|
20
|
+
begin: async () => {
|
|
21
|
+
throw new Error("begin not used in these tests");
|
|
22
|
+
},
|
|
23
|
+
} as unknown as DbRunner;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("unsafeRawForDeclaredStep", () => {
|
|
27
|
+
test("throws AccessDeniedError without a grant, without reporting", () => {
|
|
28
|
+
const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
|
|
29
|
+
const tdb = createTenantDb(fakeRunner(), tenantId, "tenant", undefined, undefined, undefined, {
|
|
30
|
+
report: (kind, reason) => {
|
|
31
|
+
reports.push({ kind, reason });
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(() => unsafeRawForDeclaredStep(tdb, REASON)).toThrow(AccessDeniedError);
|
|
36
|
+
expect(reports).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns exactly the bound runner and reports unsafe-raw exactly once, with a grant", () => {
|
|
40
|
+
const runner = fakeRunner();
|
|
41
|
+
const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
|
|
42
|
+
const tdb = createTenantDb(runner, tenantId, "tenant", undefined, undefined, undefined, {
|
|
43
|
+
unsafeRaw: { reason: "handler declared unsafeRaw" },
|
|
44
|
+
report: (kind, reason) => {
|
|
45
|
+
reports.push({ kind, reason });
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(unsafeRawForDeclaredStep(tdb, REASON)).toBe(runner);
|
|
50
|
+
expect(reports).toEqual([{ kind: "unsafe-raw", reason: REASON }]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("throws for an empty or whitespace-only reason", () => {
|
|
54
|
+
const tdb = createTenantDb(fakeRunner(), tenantId, "tenant", undefined, undefined, undefined, {
|
|
55
|
+
unsafeRaw: { reason: "handler declared unsafeRaw" },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(() => unsafeRawForDeclaredStep(tdb, "")).toThrow(/non-empty reason/);
|
|
59
|
+
expect(() => unsafeRawForDeclaredStep(tdb, " ")).toThrow(/non-empty reason/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("throws InternalError for a TenantDb not built by createTenantDb", () => {
|
|
63
|
+
const handBuilt = { tenantId, mode: "tenant" } as unknown as TenantDb;
|
|
64
|
+
expect(() => unsafeRawForDeclaredStep(handBuilt, REASON)).toThrow(InternalError);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("throws InternalError for a throwing guard Proxy, without the proxy's get trap firing", () => {
|
|
68
|
+
let getTrapFired = false;
|
|
69
|
+
const guardProxy = new Proxy(
|
|
70
|
+
{},
|
|
71
|
+
{
|
|
72
|
+
get() {
|
|
73
|
+
getTrapFired = true;
|
|
74
|
+
throw new Error("systemScope guard: property access denied");
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
) as unknown as TenantDb;
|
|
78
|
+
|
|
79
|
+
expect(() => unsafeRawForDeclaredStep(guardProxy, REASON)).toThrow(InternalError);
|
|
80
|
+
expect(getTrapFired).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("resolves to the bound runner for a createUncheckedSystemDb holder, reporting once", () => {
|
|
84
|
+
const runner = fakeRunner();
|
|
85
|
+
const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
|
|
86
|
+
const systemDb = createTenantDb(runner, tenantId, "system");
|
|
87
|
+
const unchecked = createUncheckedSystemDb(systemDb, undefined, (kind, reason) => {
|
|
88
|
+
reports.push({ kind, reason });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(unsafeRawForDeclaredStep(unchecked, REASON)).toBe(runner);
|
|
92
|
+
expect(reports).toEqual([{ kind: "unsafe-raw", reason: REASON }]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("still resolves after withUnsafeRawGrant rebinds the TenantDb", () => {
|
|
96
|
+
const runner = fakeRunner();
|
|
97
|
+
const tdb = createTenantDb(runner, tenantId, "tenant", undefined, undefined, undefined, {
|
|
98
|
+
unsafeRaw: { reason: "original grant" },
|
|
99
|
+
});
|
|
100
|
+
const regranted = withUnsafeRawGrant(tdb, { reason: "rebind grant" });
|
|
101
|
+
|
|
102
|
+
expect(unsafeRawForDeclaredStep(regranted, REASON)).toBe(runner);
|
|
103
|
+
});
|
|
104
|
+
});
|