@cosmicdrift/kumiko-framework 0.155.0 → 0.156.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 +9 -38
- package/package.json +2 -2
- package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
- package/src/__tests__/raw-table.integration.test.ts +17 -40
- package/src/crypto/__tests__/pii-field-encryption.test.ts +77 -0
- package/src/crypto/index.ts +1 -0
- package/src/crypto/pii-field-encryption.ts +19 -0
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +27 -1
- package/src/db/__tests__/blind-index.integration.test.ts +16 -0
- package/src/db/__tests__/collect-table-metas.test.ts +6 -6
- package/src/db/__tests__/feature-table-sources.test.ts +7 -7
- package/src/db/__tests__/migrate-generator.test.ts +21 -0
- package/src/db/__tests__/number-field-fractional.integration.test.ts +1 -1
- package/src/db/__tests__/tenant-db-where-merge.test.ts +39 -0
- package/src/db/collect-table-metas.ts +6 -7
- package/src/db/entity-table-meta.ts +1 -1
- package/src/db/feature-table-sources.ts +5 -11
- package/src/db/migrate-generator.ts +39 -6
- package/src/db/queries/shadow-swap.ts +85 -3
- package/src/db/tenant-db.ts +8 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +102 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +16 -0
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
- package/src/engine/__tests__/field-access.test.ts +23 -1
- package/src/engine/__tests__/raw-table.test.ts +134 -84
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/boot-validator/__tests__/config-deps.test.ts +52 -2
- package/src/engine/boot-validator/action-wiring.ts +2 -2
- package/src/engine/boot-validator/config-deps.ts +35 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/pii-retention.ts +35 -3
- package/src/engine/build-config-feature-schema.ts +3 -3
- package/src/engine/config-helpers.ts +2 -0
- package/src/engine/define-feature.ts +2 -6
- package/src/engine/entity-handlers.ts +2 -4
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +170 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +31 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +6 -1
- package/src/engine/feature-ast/extractors/events.ts +322 -0
- package/src/engine/feature-ast/extractors/handlers.ts +222 -0
- package/src/engine/feature-ast/extractors/hooks.ts +243 -0
- package/src/engine/feature-ast/extractors/index.ts +32 -24
- package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
- package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
- package/src/engine/feature-ast/extractors/round5.ts +3 -3
- package/src/engine/feature-ast/parse.ts +3 -3
- package/src/engine/feature-ast/render.ts +16 -11
- package/src/engine/feature-builder-state.ts +0 -3
- package/src/engine/feature-config-events-jobs.ts +3 -6
- package/src/engine/feature-entity-handlers.ts +9 -1
- package/src/engine/feature-ui-extensions.ts +10 -40
- package/src/engine/field-access.ts +13 -2
- package/src/engine/object-form.ts +15 -0
- package/src/engine/registry-facade.ts +4 -0
- package/src/engine/registry-ingest.ts +19 -29
- package/src/engine/registry-state.ts +1 -7
- package/src/engine/registry-validate.ts +5 -28
- package/src/engine/registry.ts +0 -2
- package/src/engine/types/config.ts +7 -0
- package/src/engine/types/feature.ts +40 -68
- package/src/engine/types/fields.ts +6 -0
- package/src/engine/types/index.ts +0 -3
- package/src/es-ops/README.md +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
- package/src/es-ops/context.ts +4 -2
- package/src/es-ops/types.ts +8 -1
- package/src/pipeline/msp-rebuild.ts +4 -0
- package/src/pipeline/projection-rebuild.ts +35 -0
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +8 -1
- package/src/stack/test-stack.ts +17 -2
- package/src/ui-types/index.ts +1 -0
- package/src/engine/__tests__/unmanaged-table.test.ts +0 -172
- package/src/engine/feature-ast/extractors/round4.ts +0 -1227
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
// collectTableMetas — kanonische ENTITY_METAS-Quelle für `kumiko schema
|
|
2
2
|
// generate`. Erfasst dieselben Tabellen-Quellen wie der setupTestStack-
|
|
3
|
-
// auto-push (entities,
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// crashte (#255).
|
|
3
|
+
// auto-push (entities, rawTables, projections, multiStreamProjections) —
|
|
4
|
+
// die frühere Template-Variante sammelte nur entities + rawTables, wodurch
|
|
5
|
+
// projection-only-Tabellen (z.B. billing-foundation read_subscriptions) nie
|
|
6
|
+
// in Migrations landeten und der erste Prod-Write crashte (#255).
|
|
8
7
|
|
|
9
8
|
import type { FeatureDefinition } from "../engine/types";
|
|
10
9
|
import { compareByCodepoint } from "../utils";
|
|
@@ -60,11 +59,11 @@ export function collectTableMetas(
|
|
|
60
59
|
metas.push(meta);
|
|
61
60
|
byName.set(meta.tableName, { meta, origin: `entity "${name}" (${feature.name})` });
|
|
62
61
|
}
|
|
63
|
-
for (const entry of Object.values(feature.
|
|
62
|
+
for (const entry of Object.values(feature.rawTables)) {
|
|
64
63
|
metas.push(entry.meta);
|
|
65
64
|
byName.set(entry.meta.tableName, {
|
|
66
65
|
meta: entry.meta,
|
|
67
|
-
origin: `
|
|
66
|
+
origin: `rawTable "${entry.name}" (${feature.name})`,
|
|
68
67
|
});
|
|
69
68
|
}
|
|
70
69
|
}
|
|
@@ -95,7 +95,7 @@ export type EntityTableMeta = {
|
|
|
95
95
|
// discriminator nutzen um Warnungen zu rendern ("X tables are unmanaged").
|
|
96
96
|
readonly source: "managed" | "unmanaged";
|
|
97
97
|
// PII-Subject-annotated field names (pii/userOwned/tenantOwned). Set by
|
|
98
|
-
// buildEntityTableMeta so the registry can reject r.
|
|
98
|
+
// buildEntityTableMeta so the registry can reject r.rawTable stores
|
|
99
99
|
// whose direct writes would skip the executor's encryption (#820).
|
|
100
100
|
readonly piiSubjectFields?: readonly string[];
|
|
101
101
|
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Single enumeration of every table-bearing registration on a feature
|
|
2
|
-
// (r.projection, r.multiStreamProjection with table, r.rawTable
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// (r.projection, r.multiStreamProjection with table, r.rawTable).
|
|
3
|
+
// Consumed by BOTH the setupTestStack auto-push and collectTableMetas —
|
|
4
|
+
// one list, so test-DB-push and `kumiko schema generate` cannot drift
|
|
5
|
+
// apart again (#255).
|
|
6
6
|
// A new table-bearing registrar must be added HERE, not in the consumers.
|
|
7
7
|
|
|
8
8
|
import type { FeatureDefinition } from "../engine/types";
|
|
@@ -30,13 +30,7 @@ export function enumerateFeatureTableSources(
|
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
32
|
for (const [name, raw] of Object.entries(feature.rawTables)) {
|
|
33
|
-
sources.push({ table: raw.
|
|
34
|
-
}
|
|
35
|
-
for (const entry of Object.values(feature.unmanagedTables)) {
|
|
36
|
-
sources.push({
|
|
37
|
-
table: entry.meta,
|
|
38
|
-
origin: `unmanagedTable "${entry.name}" (${feature.name})`,
|
|
39
|
-
});
|
|
33
|
+
sources.push({ table: raw.meta, origin: `rawTable "${name}" (${feature.name})` });
|
|
40
34
|
}
|
|
41
35
|
return sources;
|
|
42
36
|
}
|
|
@@ -202,13 +202,40 @@ export function diffSnapshots(prev: Snapshot | null, next: Snapshot): SchemaDiff
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
// Managed projections are event-stream derivatives: in-place-unsafe changes (NOT NULL w/o default, UNIQUE, SET NOT NULL, type change, dropped col) → DROP+CREATE + replay; additive-safe changes stay cheap ALTERs.
|
|
205
|
+
function describeRecreateReasons(td: TableDiff): readonly string[] {
|
|
206
|
+
const reasons: string[] = [];
|
|
207
|
+
if (td.droppedColumns.length > 0) {
|
|
208
|
+
reasons.push(`dropped column(s): ${td.droppedColumns.join(", ")}`);
|
|
209
|
+
}
|
|
210
|
+
const notNullNoDefault = td.newColumns
|
|
211
|
+
.filter((c) => c.notNull && c.defaultSql === undefined)
|
|
212
|
+
.map((c) => c.name);
|
|
213
|
+
if (notNullNoDefault.length > 0) {
|
|
214
|
+
reasons.push(`new NOT NULL column(s) without default: ${notNullNoDefault.join(", ")}`);
|
|
215
|
+
}
|
|
216
|
+
const newUniqueIndexes = td.newIndexes
|
|
217
|
+
.filter((idx) => idx.unique === true)
|
|
218
|
+
.map((idx) => idx.name);
|
|
219
|
+
if (newUniqueIndexes.length > 0) {
|
|
220
|
+
reasons.push(`new UNIQUE index(es): ${newUniqueIndexes.join(", ")}`);
|
|
221
|
+
}
|
|
222
|
+
const madeNotNull = td.changedColumns
|
|
223
|
+
.filter((c) => c.nullabilityChanged?.to === true)
|
|
224
|
+
.map((c) => c.name);
|
|
225
|
+
if (madeNotNull.length > 0) {
|
|
226
|
+
reasons.push(`column(s) set NOT NULL: ${madeNotNull.join(", ")}`);
|
|
227
|
+
}
|
|
228
|
+
const typeChanged = td.changedColumns
|
|
229
|
+
.filter((c) => c.typeChanged !== undefined)
|
|
230
|
+
.map((c) => c.name);
|
|
231
|
+
if (typeChanged.length > 0) {
|
|
232
|
+
reasons.push(`column type change: ${typeChanged.join(", ")}`);
|
|
233
|
+
}
|
|
234
|
+
return reasons;
|
|
235
|
+
}
|
|
236
|
+
|
|
205
237
|
export function managedChangeRequiresRecreate(td: TableDiff): boolean {
|
|
206
|
-
|
|
207
|
-
if (td.newColumns.some((c) => c.notNull && c.defaultSql === undefined)) return true;
|
|
208
|
-
if (td.newIndexes.some((idx) => idx.unique === true)) return true;
|
|
209
|
-
return td.changedColumns.some(
|
|
210
|
-
(c) => c.nullabilityChanged?.to === true || c.typeChanged !== undefined,
|
|
211
|
-
);
|
|
238
|
+
return describeRecreateReasons(td).length > 0;
|
|
212
239
|
}
|
|
213
240
|
|
|
214
241
|
// --- SQL-Render ---------------------------------------------------------
|
|
@@ -296,6 +323,12 @@ export function renderMigrationSql(
|
|
|
296
323
|
lines.push(`-- ${td.tableName}`);
|
|
297
324
|
if (td.nextMeta.source === "managed" && managedChangeRequiresRecreate(td)) {
|
|
298
325
|
lines.push("-- managed projection — recreated + rebuilt from events (see .rebuild.json)");
|
|
326
|
+
lines.push(
|
|
327
|
+
`-- WARN: destructive change (${describeRecreateReasons(td).join("; ")}) forces DROP+CREATE + full event replay.`,
|
|
328
|
+
);
|
|
329
|
+
lines.push(
|
|
330
|
+
"-- Consider an Expand/Contract split across two releases to avoid the rebuild — see docs/guides/expand-contract-managed-projections.md.",
|
|
331
|
+
);
|
|
299
332
|
lines.push(`DROP TABLE IF EXISTS ${quoteIdent(td.tableName)};`);
|
|
300
333
|
lines.push(...renderTableDdl(td.nextMeta));
|
|
301
334
|
lines.push("");
|
|
@@ -173,8 +173,8 @@ const UNREACHABLE_SAMPLE_LIMIT = 20;
|
|
|
173
173
|
// by the #494 backfill-then-rebuild flow.
|
|
174
174
|
// Checking event existence INCLUDING archived streams leaves every one of them
|
|
175
175
|
// alone: those rows all have a real event, so they are not ghosts. Column-level
|
|
176
|
-
// drift
|
|
177
|
-
//
|
|
176
|
+
// drift is a SEPARATE, non-blocking check — see countColumnDrift below (#916,
|
|
177
|
+
// resolves the #722 open question: observe, don't block).
|
|
178
178
|
//
|
|
179
179
|
// Implicit projections only (caller-gated). aggregate_id and the entity id are
|
|
180
180
|
// both uuid, so the anti-join probes the events index without a cast.
|
|
@@ -207,12 +207,94 @@ export async function assertNoUnreachableLiveRows(
|
|
|
207
207
|
`projection-rebuild "${projectionName}": ${countLabel} live rows in "${tableName}" have no ` +
|
|
208
208
|
`event in the projection's source streams and cannot be reconstructed by replay — the swap ` +
|
|
209
209
|
`would silently drop them (ids: ${ids.join(", ")}). A handler direct-inserted these rows ` +
|
|
210
|
-
`without emitting a .created event. Fix: register the table with r.
|
|
210
|
+
`without emitting a .created event. Fix: register the table with r.rawTable(meta, ` +
|
|
211
211
|
`{ reason }) to opt out of rebuild, or emit the missing events. See ` +
|
|
212
212
|
`docs/reference/entity-write-patterns.md. Rebuild aborted; live table untouched.`,
|
|
213
213
|
);
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
// Columns ignored by countColumnDrift — the one PROVABLY legitimate class of
|
|
217
|
+
// live-vs-shadow divergence. A blind-index column (`<field>_bidx`) is
|
|
218
|
+
// recomputed to NULL on GDPR key-shredding; the NULL is the intended end
|
|
219
|
+
// state, not drift. Everything else that legitimately diverges (archived
|
|
220
|
+
// streams, #494 backfill) either never reaches this comparison (archived rows
|
|
221
|
+
// are absent from the shadow entirely, see swapShadowIntoLive) or IS real
|
|
222
|
+
// column drift that the #494 backfill-then-rebuild flow relies on replay to
|
|
223
|
+
// heal — reporting it (without blocking) is correct, not a false positive.
|
|
224
|
+
const COLUMN_DRIFT_SAMPLE_LIMIT = 20;
|
|
225
|
+
|
|
226
|
+
export type ColumnDriftResult = {
|
|
227
|
+
readonly rowCount: number;
|
|
228
|
+
// Capped sample of "<id>.<column>" pairs for log/ops triage.
|
|
229
|
+
readonly sample: readonly string[];
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Runs INSIDE the rebuild tx, in the same slot as assertNoUnreachableLiveRows
|
|
233
|
+
// (after replay settles, before swapShadowIntoLive). Non-blocking counterpart
|
|
234
|
+
// to the ghost-row guard: reports live rows whose column values differ from
|
|
235
|
+
// the freshly-replayed shadow, WITHOUT aborting the swap (#916, resolves the
|
|
236
|
+
// #722 open question in favor of observe-not-block).
|
|
237
|
+
//
|
|
238
|
+
// Why non-blocking: a legacy column direct-written before its handler emitted
|
|
239
|
+
// events (#494) diverges from replay by design — that divergence is exactly
|
|
240
|
+
// what the backfill-then-rebuild flow relies on replay to heal. Failing hard
|
|
241
|
+
// here would make rebuild mutually exclusive with that shipped healing path.
|
|
242
|
+
// There is no reliable metadata to distinguish "#494 healing in progress" from
|
|
243
|
+
// "someone else corrupted this row" short of an open-ended per-column policy
|
|
244
|
+
// blocklist — wrong-by-default whenever a class is missed. So: surface it,
|
|
245
|
+
// don't police it. The caller logs the result; ops decides.
|
|
246
|
+
//
|
|
247
|
+
// Caveat: sensitive CUSTOM fields still diverge until #972 (Subject-DEK
|
|
248
|
+
// design) — regular sensitive fields carry event-payload ciphertext parity
|
|
249
|
+
// post-#973 and don't drift. Both are reported the same as any other column
|
|
250
|
+
// drift; this is deliberate (see module comment above), not an oversight.
|
|
251
|
+
//
|
|
252
|
+
// Relies on assertLiveColumnsMatchMeta having already run: live/shadow/meta
|
|
253
|
+
// column sets are known to match, so the diff can walk meta.columns directly.
|
|
254
|
+
export async function countColumnDrift(
|
|
255
|
+
tx: AnyDb,
|
|
256
|
+
tableName: string,
|
|
257
|
+
meta: EntityTableMeta,
|
|
258
|
+
): Promise<ColumnDriftResult> {
|
|
259
|
+
const comparable = meta.columns.filter((c) => c.primaryKey !== true && !c.name.endsWith("_bidx"));
|
|
260
|
+
// skip: nothing to compare (id-only or all-bidx table) — no drift is possible
|
|
261
|
+
if (comparable.length === 0) return { rowCount: 0, sample: [] };
|
|
262
|
+
const t = quoteTableIdent(tableName);
|
|
263
|
+
const raw = asRawClient(tx);
|
|
264
|
+
const driftCte = `WITH drifted AS (
|
|
265
|
+
SELECT l."id" AS id, string_agg(diff.col, ',') AS drifted_columns
|
|
266
|
+
FROM public.${t} l
|
|
267
|
+
JOIN ${SCHEMA_IDENT}.${t} s ON s."id" = l."id"
|
|
268
|
+
CROSS JOIN LATERAL (
|
|
269
|
+
VALUES ${comparable
|
|
270
|
+
.map(
|
|
271
|
+
(c) =>
|
|
272
|
+
`('${c.name}', l.${quoteTableIdent(c.name)} IS DISTINCT FROM s.${quoteTableIdent(c.name)})`,
|
|
273
|
+
)
|
|
274
|
+
.join(", ")}
|
|
275
|
+
) AS diff(col, differs)
|
|
276
|
+
WHERE diff.differs
|
|
277
|
+
GROUP BY l."id"
|
|
278
|
+
)`;
|
|
279
|
+
// Two passes over `drifted`: an unbounded COUNT for the true total (replay
|
|
280
|
+
// already scanned every row this run, so a second scan here is cheap by
|
|
281
|
+
// comparison) plus a capped sample for the log. rowCount must NEVER be
|
|
282
|
+
// min(actual, LIMIT) — that would silently understate severity to ops.
|
|
283
|
+
const totalRows = await raw.unsafe<{ total: string }>(
|
|
284
|
+
`${driftCte} SELECT count(*)::text AS total FROM drifted`,
|
|
285
|
+
);
|
|
286
|
+
const rowCount = Number(totalRows[0]?.total ?? "0");
|
|
287
|
+
// skip: no drift — nothing to sample
|
|
288
|
+
if (rowCount === 0) return { rowCount: 0, sample: [] };
|
|
289
|
+
const rows = await raw.unsafe<{ id: unknown; drifted_columns: string }>(
|
|
290
|
+
`${driftCte} SELECT id, drifted_columns FROM drifted LIMIT ${COLUMN_DRIFT_SAMPLE_LIMIT}`,
|
|
291
|
+
);
|
|
292
|
+
const sample = rows.flatMap((r) =>
|
|
293
|
+
r.drifted_columns.split(",").map((col) => `${String(r.id)}.${col}`),
|
|
294
|
+
);
|
|
295
|
+
return { rowCount, sample };
|
|
296
|
+
}
|
|
297
|
+
|
|
216
298
|
// Atomic swap, INSIDE the rebuild tx, AFTER replay. Schema-qualified so the
|
|
217
299
|
// active shadow search_path can't redirect them. DROP without CASCADE: if any
|
|
218
300
|
// object depends on the live table the swap fails loud and the whole rebuild
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
asEntityTableMeta,
|
|
2
3
|
asRawClient,
|
|
3
4
|
deleteMany as bunDeleteMany,
|
|
4
5
|
fetchOne as bunFetchOne,
|
|
@@ -79,7 +80,14 @@ function tableNameOf(table: Table): string {
|
|
|
79
80
|
return typeof sym === "string" ? sym : "<unknown>";
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
// Checks the canonical EntityTableMeta (branded EntityTable's KUMIKO_META_SYMBOL
|
|
84
|
+
// or a plain buildEntityTableMeta/defineUnmanagedTable result), not a direct
|
|
85
|
+
// `table.tenantId` property read — the latter only exists on branded EntityTables
|
|
86
|
+
// and silently returned false (no tenant filter!) for plain EntityTableMeta
|
|
87
|
+
// tables like unmanaged direct-write stores, e.g. userSessionTable.
|
|
82
88
|
function hasTenantColumn(table: Table): boolean {
|
|
89
|
+
const meta = asEntityTableMeta(table);
|
|
90
|
+
if (meta) return meta.columns.some((c) => c.name === "tenant_id");
|
|
83
91
|
return (table as Record<string, unknown>)["tenantId"] !== undefined;
|
|
84
92
|
}
|
|
85
93
|
|
|
@@ -708,3 +708,105 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
708
708
|
expect(() => validateBoot([feature])).toThrow(/sortable.*cannot work/);
|
|
709
709
|
});
|
|
710
710
|
});
|
|
711
|
+
|
|
712
|
+
describe("validateBoot — piiEncrypted (kumiko-platform#231/#456)", () => {
|
|
713
|
+
test("piiEncrypted on a plain text field passes", () => {
|
|
714
|
+
const feature = defineFeature("test", (r) => {
|
|
715
|
+
r.entity(
|
|
716
|
+
"tenant",
|
|
717
|
+
createEntity({
|
|
718
|
+
fields: {
|
|
719
|
+
iban: createTextField({
|
|
720
|
+
piiEncrypted: true,
|
|
721
|
+
tenantOwned: true,
|
|
722
|
+
access: { read: ["TenantAdmin"] },
|
|
723
|
+
}),
|
|
724
|
+
},
|
|
725
|
+
}),
|
|
726
|
+
);
|
|
727
|
+
});
|
|
728
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("piiEncrypted on a non-text field throws", () => {
|
|
732
|
+
const feature = defineFeature("test", (r) => {
|
|
733
|
+
r.entity(
|
|
734
|
+
"tenant",
|
|
735
|
+
createEntity({
|
|
736
|
+
fields: {
|
|
737
|
+
amount: { ...createNumberField(), piiEncrypted: true } as unknown as ReturnType<
|
|
738
|
+
typeof createNumberField
|
|
739
|
+
>,
|
|
740
|
+
},
|
|
741
|
+
}),
|
|
742
|
+
);
|
|
743
|
+
});
|
|
744
|
+
expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*only applies to text fields/);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test("piiEncrypted without a subject annotation throws (kumiko-platform#457)", () => {
|
|
748
|
+
const feature = defineFeature("test", (r) => {
|
|
749
|
+
r.entity(
|
|
750
|
+
"tenant",
|
|
751
|
+
createEntity({
|
|
752
|
+
fields: {
|
|
753
|
+
iban: createTextField({ piiEncrypted: true }),
|
|
754
|
+
},
|
|
755
|
+
}),
|
|
756
|
+
);
|
|
757
|
+
});
|
|
758
|
+
expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*without a subject annotation/);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("piiEncrypted combined with searchable throws", () => {
|
|
762
|
+
const feature = defineFeature("test", (r) => {
|
|
763
|
+
r.entity(
|
|
764
|
+
"tenant",
|
|
765
|
+
createEntity({
|
|
766
|
+
fields: {
|
|
767
|
+
iban: createTextField({
|
|
768
|
+
piiEncrypted: true,
|
|
769
|
+
tenantOwned: true,
|
|
770
|
+
access: { read: ["TenantAdmin"] },
|
|
771
|
+
searchable: true,
|
|
772
|
+
}),
|
|
773
|
+
},
|
|
774
|
+
}),
|
|
775
|
+
);
|
|
776
|
+
});
|
|
777
|
+
expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*searchable.*cannot work/);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("piiEncrypted combined with sortable throws", () => {
|
|
781
|
+
const feature = defineFeature("test", (r) => {
|
|
782
|
+
r.entity(
|
|
783
|
+
"tenant",
|
|
784
|
+
createEntity({
|
|
785
|
+
fields: {
|
|
786
|
+
iban: createTextField({
|
|
787
|
+
piiEncrypted: true,
|
|
788
|
+
tenantOwned: true,
|
|
789
|
+
access: { read: ["TenantAdmin"] },
|
|
790
|
+
sortable: true,
|
|
791
|
+
}),
|
|
792
|
+
},
|
|
793
|
+
}),
|
|
794
|
+
);
|
|
795
|
+
});
|
|
796
|
+
expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*sortable.*cannot work/);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test("piiEncrypted without access.read throws (kumiko-platform#460)", () => {
|
|
800
|
+
const feature = defineFeature("test", (r) => {
|
|
801
|
+
r.entity(
|
|
802
|
+
"tenant",
|
|
803
|
+
createEntity({
|
|
804
|
+
fields: {
|
|
805
|
+
iban: createTextField({ piiEncrypted: true, tenantOwned: true }),
|
|
806
|
+
},
|
|
807
|
+
}),
|
|
808
|
+
);
|
|
809
|
+
});
|
|
810
|
+
expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*without.*access.*read/);
|
|
811
|
+
});
|
|
812
|
+
});
|
|
@@ -435,4 +435,20 @@ describe("buildConfigFeatureSchema — cross-feature group", () => {
|
|
|
435
435
|
});
|
|
436
436
|
expect(() => buildConfigFeatureSchema(createRegistry([feature]))).toThrow(/kebab-case/);
|
|
437
437
|
});
|
|
438
|
+
|
|
439
|
+
test("invalid (non-kebab) group value throws even on a maskless key", () => {
|
|
440
|
+
// Maskless keys never appear in the hub — group is a no-op for them at
|
|
441
|
+
// runtime — but the type contract still promises kebab-case is
|
|
442
|
+
// boot-validated regardless of `mask`. collectMaskedKeys used to check
|
|
443
|
+
// the mask-gate before the group check, so a maskless key with a bad
|
|
444
|
+
// group silently passed boot; the check now runs before the mask-gate.
|
|
445
|
+
const feature = defineFeature("badgroupmaskless", (r) => {
|
|
446
|
+
r.config({
|
|
447
|
+
keys: {
|
|
448
|
+
flag: createTenantConfig("boolean", { group: "Not Kebab!" }),
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
expect(() => buildConfigFeatureSchema(createRegistry([feature]))).toThrow(/kebab-case/);
|
|
453
|
+
});
|
|
438
454
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineFeature } from "../define-feature";
|
|
3
|
+
import { createEntity, createTextField } from "../factories";
|
|
4
|
+
|
|
5
|
+
const taskEntity = createEntity({
|
|
6
|
+
table: "crud_shorthand_tasks",
|
|
7
|
+
fields: { title: createTextField({ required: true }) },
|
|
8
|
+
softDelete: true,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("r.crud", () => {
|
|
12
|
+
test("registers the entity + full CRUD handler set, same as registerEntityCrud", () => {
|
|
13
|
+
const write = { access: { roles: ["Admin"] } } as const;
|
|
14
|
+
const read = { access: { openToAll: true } } as const;
|
|
15
|
+
|
|
16
|
+
const feature = defineFeature("via-crud", (r) => {
|
|
17
|
+
r.crud("task", taskEntity, { write, read });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(Object.keys(feature.entities ?? {})).toEqual(["task"]);
|
|
21
|
+
expect(Object.keys(feature.writeHandlers ?? {}).sort()).toEqual(
|
|
22
|
+
["task:create", "task:delete", "task:restore", "task:update"].sort(),
|
|
23
|
+
);
|
|
24
|
+
expect(Object.keys(feature.queryHandlers ?? {}).sort()).toEqual(
|
|
25
|
+
["task:detail", "task:list"].sort(),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { checkWriteFieldRoles, filterReadFields } from "../field-access";
|
|
2
|
+
import { checkWriteFieldRoles, filterReadFields, PII_MASKED_VALUE } from "../field-access";
|
|
3
3
|
import type { EntityDefinition } from "../types";
|
|
4
4
|
|
|
5
5
|
const entity: EntityDefinition = {
|
|
@@ -25,6 +25,28 @@ describe("filterReadFields", () => {
|
|
|
25
25
|
const filtered = filterReadFields(entity, row, admin);
|
|
26
26
|
expect(filtered["secret"]).toBe("visible");
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
test("masks piiEncrypted fields instead of stripping them (kumiko-platform#463)", () => {
|
|
30
|
+
const entityWithPii: EntityDefinition = {
|
|
31
|
+
fields: {
|
|
32
|
+
...entity.fields,
|
|
33
|
+
iban: {
|
|
34
|
+
type: "text",
|
|
35
|
+
piiEncrypted: true,
|
|
36
|
+
tenantOwned: true,
|
|
37
|
+
access: { read: { admin: "all" } },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const row = { id: 1, title: "Hello", secret: "hidden", iban: "DE89370400440532013000" };
|
|
42
|
+
|
|
43
|
+
const filteredForEditor = filterReadFields(entityWithPii, row, editor);
|
|
44
|
+
expect(filteredForEditor["secret"]).toBeUndefined();
|
|
45
|
+
expect(filteredForEditor["iban"]).toBe(PII_MASKED_VALUE);
|
|
46
|
+
|
|
47
|
+
const filteredForAdmin = filterReadFields(entityWithPii, row, admin);
|
|
48
|
+
expect(filteredForAdmin["iban"]).toBe("DE89370400440532013000");
|
|
49
|
+
});
|
|
28
50
|
});
|
|
29
51
|
|
|
30
52
|
describe("checkWriteFieldRoles", () => {
|