@cosmicdrift/kumiko-framework 0.289.0 → 0.291.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 +4 -4
- package/src/__tests__/field-access.integration.test.ts +7 -3
- package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
- package/src/__tests__/ownership.integration.test.ts +1 -1
- package/src/__tests__/pii-personal-migration-report-codemod.test.ts +160 -0
- package/src/api/__tests__/server-boot-guards.test.ts +42 -0
- package/src/api/__tests__/server-error-logging.test.ts +168 -17
- package/src/api/request-context.ts +31 -0
- package/src/api/request-id-middleware.ts +2 -1
- package/src/api/routes.ts +35 -3
- package/src/api/server.ts +30 -4
- package/src/changes.json +69 -0
- package/src/crypto/__tests__/blind-index.test.ts +1 -1
- package/src/crypto/__tests__/event-pii.test.ts +110 -9
- package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
- package/src/crypto/__tests__/subject-resolver.test.ts +25 -4
- package/src/crypto/subject-resolver.ts +25 -8
- package/src/db/__tests__/blind-index.integration.test.ts +1 -1
- package/src/db/__tests__/eagerload.integration.test.ts +12 -2
- package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +3 -1
- package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
- package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
- package/src/db/queries/shadow-swap.ts +35 -0
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +148 -15
- package/src/engine/__tests__/boot-validator.test.ts +226 -0
- package/src/engine/__tests__/build-app-schema.test.ts +18 -0
- package/src/engine/__tests__/engine.test.ts +87 -0
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
- package/src/engine/__tests__/factories-long-text.test.ts +6 -1
- package/src/engine/__tests__/form-money-currency-types.test.ts +90 -0
- package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
- package/src/engine/boot-validator/action-wiring.ts +2 -1
- package/src/engine/boot-validator/entity-handler.ts +44 -0
- package/src/engine/boot-validator/index.ts +7 -2
- package/src/engine/boot-validator/pii-retention.ts +8 -0
- package/src/engine/boot-validator/screens.ts +54 -12
- package/src/engine/create-app.ts +54 -0
- package/src/engine/feature-config-events-jobs.ts +19 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/qualified-name.ts +9 -0
- package/src/engine/screen-helpers.ts +1 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +32 -8
- package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
- package/src/event-store/event-store.ts +23 -2
- package/src/i18n/required-surface-keys.ts +1 -0
- package/src/jobs/__tests__/job-last-success.integration.test.ts +135 -0
- package/src/jobs/index.ts +7 -1
- package/src/jobs/job-runner.ts +104 -6
- package/src/logging/utils.ts +14 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +20 -0
- package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +96 -0
- package/src/pipeline/active-membership.ts +10 -4
- package/src/pipeline/append-event-core.ts +2 -11
- package/src/pipeline/dispatch-shared.ts +17 -5
- package/src/pipeline/event-dispatcher-delivery.ts +15 -3
- package/src/pipeline/projection-rebuild.ts +7 -0
- package/src/schema-cli.ts +21 -0
- package/src/scripts/codemod/pii-personal-migration.ts +242 -2
- package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +50 -0
- package/src/testing/e2e-generator.ts +4 -3
- package/src/ui-types/index.ts +2 -0
package/src/changes.json
CHANGED
|
@@ -1,4 +1,73 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.291.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "projection-rebuild aborts instead of silently NULLing populated blind-index columns (fw#3091)",
|
|
6
|
+
"detail": "`kumiko schema apply` rebuilds a projection through a fresh shadow table replay, and the shadow always recomputes every `<field>_bidx` column with whatever blind-index key is configured in the running process. A projection rebuilt in a process without `KUMIKO_BLIND_INDEX_KEY` set — most commonly the `migrate-db` init container that runs `kumiko schema apply` on deploy — silently swapped the live table for one where every bidx column had gone NULL, breaking equality lookups (login, password reset) with no error anywhere. `rebuildProjection` now checks, right before the swap, whether the live table already has populated bidx columns while no key is configured in this process; if so it throws and leaves the live table untouched instead of completing the swap.",
|
|
7
|
+
"migration": "Plaintext installations and the fw#1610 case (KMS configured, no blind-index\nkey) are unaffected — their bidx columns are NULL already, so there is\nnothing for the rebuild to lose. This only blocks a rebuild that would\notherwise destroy already-populated bidx columns: any process running\n`kumiko schema apply` (or another projection rebuild) against a table with\nlive blind-index data must have `KUMIKO_BLIND_INDEX_KEY` set. Wire that env\nvar into the `migrate-db` init container (or wherever schema apply runs in\ndeploy) alongside the app's own KUMIKO_BLIND_INDEX_KEY, or the rebuild aborts\ninstead of quietly breaking equality lookups."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.291.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "Declared event PII fails closed without a subject KMS (fw#2776)",
|
|
13
|
+
"detail": "`defineEvent` has required an explicit PII stance since fw#2558, but a declared stance still did not guarantee ciphertext in `kumiko_events`. Two paths leaked silently and now fail closed.\nBoot: `assertPiiBootInvariants` only looked at entity annotations, so an app whose PII lives exclusively in catalogued events booted without a `kms` adapter and wrote plaintext. It now collects events with a non-`\"none\"` stance alongside the PII entities — prod aborts, dev warns, `allowPlaintextPii: \"<reason>\"` acknowledges, same as for entities.\nAppend: `{ personal: { of: \"<ownerField>\" } }` skipped encryption whenever the owner field carried no id, so the same event type was ciphertext for user-triggered writes and plaintext for system-triggered ones with no signal. The stance now carries `whenAbsent`: `\"tenant\"` encrypts under the envelope tenant key, `\"plaintext\"` is an explicit acknowledgement that the value cannot be crypto-shredded. Registration rejects a nullable owner field without one, and an owner that is empty at append time with no declared fallback aborts the write instead of storing the value in the clear.\n`delivery:event:attempt` declares `whenAbsent: \"tenant\"` — a send whose `recipientId` is null now stores the recipient address under the tenant key instead of in plaintext.",
|
|
14
|
+
"migration": "Three things can newly fail. (1) Boot aborts with `BOOT ABORTED — ... events\n[...]` when a mounted feature declares a non-`\"none\"` `piiFields` stance and\n`runProdApp`/`runWorkerApp` gets no `kms`. Pass\n`kms: createPgKmsAdapter({ databaseUrl, platformKek })`, or acknowledge the\nplaintext with `allowPlaintextPii: \"<reason>\"` until the KMS is provisioned;\n`runDevApp` only warns. (2) Registration aborts when a\n`{ personal: { of: \"<ownerField>\" } }` stance names an owner field the payload\nschema allows to be null or undefined. Add `whenAbsent: \"tenant\"` to encrypt\nthose writes under the envelope tenant key, or `whenAbsent: \"plaintext\"` to\ndeclare that the value ships unencrypted and is not crypto-shreddable. The\ndeprecated `{ subjectField: \"<ownerField>\" }` form cannot express `whenAbsent`\n— move it to the canonical `{ personal: { of: ... } }` form. (3) `append()`\nthrows `SubjectResolutionError` when the owner field is empty at write time\nand the event declared no `whenAbsent`. Registration catches this for\nZodObject payload schemas; a non-object schema surfaces it here. An owner\nvalue that is not a non-empty string — a numeric id, an empty string — counts\nas absent, so it takes the same path and needs the same declaration.\nSeparately, `delivery:event:attempt` rows written with a null `recipientId`\nused to hold a plaintext recipient address in `kumiko_events` and in\n`store_delivery_attempts`. New rows are tenant-subject ciphertext.\n`delivery:query:log` decrypts either form, so the admin log view is unchanged;\ntooling that reads `store_delivery_attempts.recipient_address` directly must\ngo through `decryptStoredPii`. Existing plaintext rows stay readable and are\nre-encrypted by `backfillEventPiiEncryption`."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.291.0",
|
|
18
|
+
"type": "improvement",
|
|
19
|
+
"title": "Boot warns for text/longText fields without a personal stance (fw#2918).",
|
|
20
|
+
"detail": "`validatePiiAndRetention` so far only warned when an unannotated field name hit one of the PII name heuristics. It now warns for every `text`/`longText` field that declares no stance at all — naming feature, entity, field and all valid stances verbatim — so consumers can work off their own baseline before fw#2810 turns the missing stance into a compile error and a throw. Annotated fields (including `personal: false` with a reason and `personal: \"ref\"`) stay silent, other field types are untouched.\nA clean boot does not mean \"ready for fw#2810\": `validatePiiAndRetention` only walks `feature.entities[*].fields`, so embedded sub-schemas and call sites that never boot (fixtures, helper modules) produce no warning while still breaking later. The completeness instrument is and stays `guard-text-field-stance` — ready means a guard count of 0. The reverse does not hold either: the guard counts `createTextField()` call sites, the boot validator walks resolved field defs, so a raw field-def object literal warns at boot without ever showing up in the guard's baseline."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.291.0",
|
|
24
|
+
"type": "fix",
|
|
25
|
+
"title": "API handler rejections (4xx) now leave a log line instead of being silently dropped",
|
|
26
|
+
"detail": "`logServerFault` returned early for every `httpStatus < 500`, so a failing request (validation, unprocessable, rate-limited) left no log trace at all — a paid external call that 422'd was invisible end to end (offlot#117). 4xx now log on `warn` via the same fallback logger 5xx already used, with status, error code and duration only — no message, details, stack or cause, so submitted values never reach the log line. 5xx behavior on the `error` level is unchanged.\nConsumers that set `LOG_LEVEL=error`, `fatal` or `silent` suppress the new 4xx lines; anything else (including the default) now logs them. Expect more log volume on routes with frequent client-side validation failures."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.291.0",
|
|
30
|
+
"type": "improvement",
|
|
31
|
+
"title": "pii-personal-migration codemod gets a --report-stance mode (fw#2919)",
|
|
32
|
+
"detail": "`bun scripts/codemod/pii-personal-migration.ts <dir> --report-stance` scans every `createTextField`/`createLongTextField` call without a `personal` stance and classifies its field name against the `entity-handler.ts` PII name hints (direct/user-owned/user-reference/near-miss/unclassified) — no files are written."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"version": "0.291.0",
|
|
36
|
+
"type": "improvement",
|
|
37
|
+
"title": "Generic job-liveness metric kumiko_job_last_success_timestamp_seconds{job} (fw#3052).",
|
|
38
|
+
"detail": "The job-runner now stamps a standard gauge with the Unix timestamp of the last successful run of every job registered via `r.job`, so any consumer gets a real dead-man for all its crons with `time() - kumiko_job_last_success_timestamp_seconds{job=\"…\"} > <interval + buffer>`. A failed run leaves the value untouched. The k8s CronJob alerts never covered these jobs — they run in-process in a long-lived pod and create no CronJob object. Note that the series is absent until the first success after a restart; `docs/reference/job-liveness-metric.md` explains the `for:` that implies for the alert side."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"version": "0.291.0",
|
|
42
|
+
"type": "breaking",
|
|
43
|
+
"title": "money fields on actionForm/secretMint screens declare their currency source (fw#2839)",
|
|
44
|
+
"detail": "`actionForm` and `secretMint` have no entity, so their money fields never received `entity.defaultCurrency`: an untouched one seeded a bare `0` that the handler's zod schema then rejected on submit. fw#2763 closed the prefill half of this; the default half stayed open. `MoneyCurrencySource` gains `{ kind: \"literal\", code }` next to the existing `{ kind: \"tenant\" }`, and the field maps of `actionForm`, `secretMint` and its `confirm` step are narrowed so a money field there requires `currency` — enforced by the compiler at bump time and by the boot validator for untyped callers. A literal code is checked against the app's `currencies` list, the same rule `entity.defaultCurrency` already follows. Entity fields, embedded-list money cells and `configEdit`'s plain-number contract are unchanged.",
|
|
45
|
+
"migration": "Only affects entity-less form screens — `actionForm`, `secretMint` and a\nsecretMint's `confirm` step — that hold a `money` field. Entity money fields\nare unchanged (`entity.defaultCurrency` is already boot-enforced for them),\nas are embedded-list money cells (currency at the head, fw#2764) and\n`configEdit`, which keeps its plain-number contract.\nAdd a `currency` to each money field in such a screen's `fields` map:\n`currency: { kind: \"literal\", code: \"EUR\" }` for one fixed currency, or\n`currency: { kind: \"tenant\" }` for the tenant's own currency.\nA `literal` code must be in the app's `currencies` list (`createApp({ currencies })`,\nwhich already includes the defaults). A `tenant`-declared field resolves through\nthe tenant-settings bundle and holds the form until the value has landed, so that\nbundle has to be mounted. Missing declarations fail at compile time; an untyped\ncaller fails at boot with the screen and field name in the message."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"version": "0.291.0",
|
|
49
|
+
"type": "improvement",
|
|
50
|
+
"title": "Reference fields can source their picker from a query handler",
|
|
51
|
+
"detail": "`labelField` names one column of the referenced entity, so an entity whose identity is composed from joined rows — a lease identified by its tenant and unit, not by any column on the lease row — has no right answer, only a least-wrong one, and its picker lists raw dates or UUIDs. `ReferenceFieldDef.optionsQuery` (also on a reference sub-field of an embedded field) names a query handler that returns `{ rows: { id, label }[] }` and receives `{ limit, search? }` like the default list handler, so the app composes the label itself. The picker, the read-only display of a reference value and an embedded-list reference cell all read it; the QN is pinned at boot against the registered handlers, the same treatment `DashboardFilterDefinition.optionsQuery` gets.\nIt is additive, not a replacement: `labelField` keeps serving the paths a query handler cannot back, since list cells, `searchable` and `sortable` all resolve to an SQL column on the referenced table. A field without `optionsQuery` behaves exactly as before."
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"version": "0.290.0",
|
|
55
|
+
"type": "improvement",
|
|
56
|
+
"title": "Event-Attribution: metadata.feature + metadata.handler auf jedem Event",
|
|
57
|
+
"detail": "event-store.append() stempelt Feature und Handler-Namen aus dem Ausfuehrungsscope (requestContext) auf jedes geschriebene Event. Dispatch-Handler, Entity-Executor-Writes, MSP-Applies und Jobs sind damit ohne Signaturaenderung attribuiert; Appends ausserhalb eines Scopes tragen UNATTRIBUTED_ORIGIN. appendRaw/appendRawBatch bleiben unveraendert."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"version": "0.290.0",
|
|
61
|
+
"type": "fix",
|
|
62
|
+
"title": "Section readers see groups[].fields",
|
|
63
|
+
"detail": "Boot-guard and E2E-generator now flatten section.groups via sectionFieldSpecs instead of iterating section.fields only: a function renderer inside groups[].fields fails at boot, and generated E2E specs cover group fields (required fields, fill ops, text assertions)."
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"version": "0.290.0",
|
|
67
|
+
"type": "improvement",
|
|
68
|
+
"title": "The tenant-teardown 410 gate is derived from the mounted tenantLifecycleStatus provider (fw#2881).",
|
|
69
|
+
"detail": "buildServer now resolves the EXT_TENANT_LIFECYCLE_STATUS extension point itself, so every server with tenant-lifecycle mounted rejects requests against a tenant in teardown with 410 tenant_unavailable — prod, dev and test stacks can no longer drift because one entrypoint forgot the wiring. An explicit auth.resolveTenantLifecycleStatus still takes precedence and stays the escape hatch. A mounted provider without context.db is a boot error instead of a silently skipped gate."
|
|
70
|
+
},
|
|
2
71
|
{
|
|
3
72
|
"version": "0.289.0",
|
|
4
73
|
"type": "breaking",
|
|
@@ -25,7 +25,7 @@ const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
|
|
|
25
25
|
const userLikeEntity = createEntity({
|
|
26
26
|
fields: {
|
|
27
27
|
email: createTextField({ required: true, personal: "self", find: "exact" }),
|
|
28
|
-
role: createTextField(),
|
|
28
|
+
role: createTextField({ personal: false, reason: "test_fixture" }),
|
|
29
29
|
},
|
|
30
30
|
table: "bidx_users",
|
|
31
31
|
});
|
|
@@ -30,6 +30,12 @@ const attemptSchema = z.object({
|
|
|
30
30
|
status: z.string(),
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
const requiredOwnerSchema = z.object({
|
|
34
|
+
recipientId: z.string(),
|
|
35
|
+
recipientAddress: z.string().nullable(),
|
|
36
|
+
status: z.string(),
|
|
37
|
+
});
|
|
38
|
+
|
|
33
39
|
const EVENT_TYPE = "mailer:event:attempt";
|
|
34
40
|
|
|
35
41
|
const ENVELOPE: EventSubjectEnvelope = {
|
|
@@ -54,18 +60,28 @@ describe("normalizeEventPiiSubject", () => {
|
|
|
54
60
|
expect(normalizeEventPiiSubject({ personal: { of: "recipientId" } })).toEqual({
|
|
55
61
|
kind: "user",
|
|
56
62
|
ownerField: "recipientId",
|
|
63
|
+
whenAbsent: undefined,
|
|
57
64
|
});
|
|
58
65
|
expect(normalizeEventPiiSubject({ subjectField: "recipientId" })).toEqual({
|
|
59
66
|
kind: "user",
|
|
60
67
|
ownerField: "recipientId",
|
|
68
|
+
whenAbsent: undefined,
|
|
61
69
|
});
|
|
62
70
|
});
|
|
71
|
+
|
|
72
|
+
test("whenAbsent rides along on the canonical form", () => {
|
|
73
|
+
expect(
|
|
74
|
+
normalizeEventPiiSubject({ personal: { of: "recipientId", whenAbsent: "tenant" } }),
|
|
75
|
+
).toEqual({ kind: "user", ownerField: "recipientId", whenAbsent: "tenant" });
|
|
76
|
+
});
|
|
63
77
|
});
|
|
64
78
|
|
|
65
79
|
describe("defineEvent piiFields validation", () => {
|
|
66
80
|
test("valid piiFields land on the EventDef and in the registry catalog", () => {
|
|
81
|
+
// The deprecated subjectField form cannot express whenAbsent, so it only
|
|
82
|
+
// registers against a schema whose owner field is always populated.
|
|
67
83
|
const feature = defineFeature("mailer", (r) => {
|
|
68
|
-
r.defineEvent("attempt",
|
|
84
|
+
r.defineEvent("attempt", requiredOwnerSchema, {
|
|
69
85
|
piiFields: { recipientAddress: { subjectField: "recipientId" } },
|
|
70
86
|
});
|
|
71
87
|
});
|
|
@@ -108,12 +124,12 @@ describe("defineEvent piiFields validation", () => {
|
|
|
108
124
|
test("valid canonical personal.of piiFields land on the EventDef and in the registry catalog", () => {
|
|
109
125
|
const feature = defineFeature("mailer", (r) => {
|
|
110
126
|
r.defineEvent("attempt", attemptSchema, {
|
|
111
|
-
piiFields: { recipientAddress: { personal: { of: "recipientId" } } },
|
|
127
|
+
piiFields: { recipientAddress: { personal: { of: "recipientId", whenAbsent: "tenant" } } },
|
|
112
128
|
});
|
|
113
129
|
});
|
|
114
130
|
createRegistry([feature]);
|
|
115
131
|
expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
|
|
116
|
-
recipientAddress: { personal: { of: "recipientId" } },
|
|
132
|
+
recipientAddress: { personal: { of: "recipientId", whenAbsent: "tenant" } },
|
|
117
133
|
});
|
|
118
134
|
});
|
|
119
135
|
|
|
@@ -206,6 +222,41 @@ describe("defineEvent piiFields validation", () => {
|
|
|
206
222
|
}),
|
|
207
223
|
).toThrow(/piiFields references "nope"/);
|
|
208
224
|
});
|
|
225
|
+
|
|
226
|
+
test("a nullable owner field without a whenAbsent stance fails registration", () => {
|
|
227
|
+
expect(() =>
|
|
228
|
+
defineFeature("mailer", (r) => {
|
|
229
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
230
|
+
piiFields: { recipientAddress: { personal: { of: "recipientId" } } },
|
|
231
|
+
});
|
|
232
|
+
}),
|
|
233
|
+
).toThrow(/allows to be null\/undefined/);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a nullable owner field with a declared whenAbsent registers", () => {
|
|
237
|
+
const feature = defineFeature("mailer", (r) => {
|
|
238
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
239
|
+
piiFields: { recipientAddress: { personal: { of: "recipientId", whenAbsent: "tenant" } } },
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
expect(feature.events["attempt"]?.piiFields).toEqual({
|
|
243
|
+
recipientAddress: { personal: { of: "recipientId", whenAbsent: "tenant" } },
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("a non-nullable owner field needs no whenAbsent", () => {
|
|
248
|
+
const required = z.object({
|
|
249
|
+
recipientId: z.string(),
|
|
250
|
+
recipientAddress: z.string(),
|
|
251
|
+
});
|
|
252
|
+
expect(() =>
|
|
253
|
+
defineFeature("mailer", (r) => {
|
|
254
|
+
r.defineEvent("attempt", required, {
|
|
255
|
+
piiFields: { recipientAddress: { personal: { of: "recipientId" } } },
|
|
256
|
+
});
|
|
257
|
+
}),
|
|
258
|
+
).not.toThrow();
|
|
259
|
+
});
|
|
209
260
|
});
|
|
210
261
|
|
|
211
262
|
describe("encryptEventPayloadPii", () => {
|
|
@@ -255,17 +306,67 @@ describe("encryptEventPayloadPii", () => {
|
|
|
255
306
|
expect(String(canonical["recipientAddress"])).toContain("user:u-1");
|
|
256
307
|
});
|
|
257
308
|
|
|
258
|
-
|
|
309
|
+
const systemPayload = {
|
|
310
|
+
recipientId: null,
|
|
311
|
+
recipientAddress: "ops@example.com",
|
|
312
|
+
status: "sent",
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
test("null subject field without a whenAbsent stance fails the append closed", async () => {
|
|
259
316
|
catalogWithAttempt();
|
|
260
317
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
318
|
+
expect(encryptEventPayloadPii(EVENT_TYPE, systemPayload, ENVELOPE)).rejects.toThrow(
|
|
319
|
+
/carries no id and the event declares no whenAbsent fallback/,
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('null subject field with whenAbsent: "tenant" encrypts under the envelope tenant key', async () => {
|
|
324
|
+
configureEventPiiCatalog(
|
|
325
|
+
new Map([
|
|
326
|
+
[
|
|
327
|
+
EVENT_TYPE,
|
|
328
|
+
{ recipientAddress: { personal: { of: "recipientId", whenAbsent: "tenant" } } },
|
|
329
|
+
],
|
|
330
|
+
]),
|
|
331
|
+
);
|
|
332
|
+
const kms = new InMemoryKmsAdapter();
|
|
333
|
+
configurePiiSubjectKms(kms);
|
|
334
|
+
|
|
335
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, systemPayload, ENVELOPE);
|
|
336
|
+
expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
|
|
337
|
+
expect(String(out["recipientAddress"])).toContain(`tenant:${ENVELOPE.tenantId}`);
|
|
338
|
+
|
|
339
|
+
const back = await decryptPiiFieldValues(out, ["recipientAddress"], kms, { requestId: "test" });
|
|
340
|
+
expect(back["recipientAddress"]).toBe("ops@example.com");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('null subject field with whenAbsent: "plaintext" is an acknowledged passthrough', async () => {
|
|
344
|
+
configureEventPiiCatalog(
|
|
345
|
+
new Map([
|
|
346
|
+
[
|
|
347
|
+
EVENT_TYPE,
|
|
348
|
+
{ recipientAddress: { personal: { of: "recipientId", whenAbsent: "plaintext" } } },
|
|
349
|
+
],
|
|
350
|
+
]),
|
|
351
|
+
);
|
|
352
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
266
353
|
expect(await encryptEventPayloadPii(EVENT_TYPE, systemPayload, ENVELOPE)).toBe(systemPayload);
|
|
267
354
|
});
|
|
268
355
|
|
|
356
|
+
test('whenAbsent: "tenant" still prefers the user key when the owner field is populated', async () => {
|
|
357
|
+
configureEventPiiCatalog(
|
|
358
|
+
new Map([
|
|
359
|
+
[
|
|
360
|
+
EVENT_TYPE,
|
|
361
|
+
{ recipientAddress: { personal: { of: "recipientId", whenAbsent: "tenant" } } },
|
|
362
|
+
],
|
|
363
|
+
]),
|
|
364
|
+
);
|
|
365
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
366
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE);
|
|
367
|
+
expect(String(out["recipientAddress"])).toContain("user:u-1");
|
|
368
|
+
});
|
|
369
|
+
|
|
269
370
|
test("null pii value passes through", async () => {
|
|
270
371
|
catalogWithAttempt();
|
|
271
372
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
@@ -28,7 +28,7 @@ const ENTITY_NAME = "pii-entity";
|
|
|
28
28
|
const userLikeEntity = createEntity({
|
|
29
29
|
fields: {
|
|
30
30
|
email: createTextField({ required: true, personal: "self", find: "none" }),
|
|
31
|
-
role: createTextField(),
|
|
31
|
+
role: createTextField({ personal: false, reason: "test_fixture" }),
|
|
32
32
|
},
|
|
33
33
|
table: "pii_users",
|
|
34
34
|
});
|
|
@@ -39,7 +39,7 @@ const commentEntity = createEntity({
|
|
|
39
39
|
personal: { of: "authorId" },
|
|
40
40
|
find: "none",
|
|
41
41
|
}),
|
|
42
|
-
authorId: createTextField({ required: true }),
|
|
42
|
+
authorId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
43
43
|
},
|
|
44
44
|
table: "pii_comments",
|
|
45
45
|
});
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
const userLikeEntity = createEntity({
|
|
12
12
|
fields: {
|
|
13
13
|
email: createTextField({ required: true, personal: "self", find: "none" }),
|
|
14
|
-
role: createTextField(),
|
|
14
|
+
role: createTextField({ personal: false, reason: "test_fixture" }),
|
|
15
15
|
},
|
|
16
16
|
table: "resolver_users",
|
|
17
17
|
idType: "uuid",
|
|
@@ -23,7 +23,7 @@ const commentEntity = createEntity({
|
|
|
23
23
|
personal: { of: "authorId" },
|
|
24
24
|
find: "none",
|
|
25
25
|
}),
|
|
26
|
-
authorId: createTextField({ required: true }),
|
|
26
|
+
authorId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
27
27
|
},
|
|
28
28
|
table: "resolver_comments",
|
|
29
29
|
});
|
|
@@ -154,16 +154,37 @@ describe("resolveEventSubject (fw#2801)", () => {
|
|
|
154
154
|
expect(subject).toEqual({ kind: "user", userId: UUID_A });
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
test("personal: { of } with a missing owner value
|
|
157
|
+
test("personal: { of } with a missing owner value and no whenAbsent → throws (fw#2776)", () => {
|
|
158
|
+
expect(() =>
|
|
159
|
+
resolveEventSubject(
|
|
160
|
+
"note",
|
|
161
|
+
{ personal: { of: "authorId" } },
|
|
162
|
+
{ authorId: null },
|
|
163
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
|
|
164
|
+
),
|
|
165
|
+
).toThrow(/carries no id and the event declares no whenAbsent fallback/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('personal: { of, whenAbsent: "plaintext" } with a missing owner → null (fw#2776)', () => {
|
|
158
169
|
const subject = resolveEventSubject(
|
|
159
170
|
"note",
|
|
160
|
-
{ personal: { of: "authorId" } },
|
|
171
|
+
{ personal: { of: "authorId", whenAbsent: "plaintext" } },
|
|
161
172
|
{ authorId: null },
|
|
162
173
|
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
|
|
163
174
|
);
|
|
164
175
|
expect(subject).toBeNull();
|
|
165
176
|
});
|
|
166
177
|
|
|
178
|
+
test('personal: { of, whenAbsent: "tenant" } with a missing owner → envelope tenant (fw#2776)', () => {
|
|
179
|
+
const subject = resolveEventSubject(
|
|
180
|
+
"note",
|
|
181
|
+
{ personal: { of: "authorId", whenAbsent: "tenant" } },
|
|
182
|
+
{ authorId: null },
|
|
183
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
|
|
184
|
+
);
|
|
185
|
+
expect(subject).toEqual({ kind: "tenant", tenantId: EVENT_TENANT });
|
|
186
|
+
});
|
|
187
|
+
|
|
167
188
|
test('personal: "tenant" → subject from the envelope tenantId', () => {
|
|
168
189
|
const subject = resolveEventSubject(
|
|
169
190
|
"note",
|
|
@@ -135,11 +135,19 @@ export interface EventSubjectEnvelope {
|
|
|
135
135
|
readonly aggregateId: string;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
function resolveEnvelopeTenant(fieldName: string, envelope: EventSubjectEnvelope): SubjectId {
|
|
139
|
+
if (nonEmptyString(envelope.tenantId) === null) {
|
|
140
|
+
throw new SubjectResolutionError(fieldName, "event envelope tenantId is empty");
|
|
141
|
+
}
|
|
142
|
+
return { kind: "tenant", tenantId: envelope.tenantId };
|
|
143
|
+
}
|
|
144
|
+
|
|
138
145
|
// Same resolver for the live-append path (encryptEventPayloadPii) and the
|
|
139
146
|
// backfill catalog path (backfillEventPiiEncryption) — the reason the two
|
|
140
|
-
// can never encrypt the same field under different subjects. "user"
|
|
141
|
-
//
|
|
142
|
-
// "tenant"/"self" throw
|
|
147
|
+
// can never encrypt the same field under different subjects. A "user"
|
|
148
|
+
// subject whose owner field is empty falls back to the declared whenAbsent
|
|
149
|
+
// stance and throws when none is declared (fw#2776); "tenant"/"self" throw
|
|
150
|
+
// on an empty envelope, since their facts are structural.
|
|
143
151
|
export function resolveEventSubject(
|
|
144
152
|
fieldName: string,
|
|
145
153
|
spec: EventPiiSubject,
|
|
@@ -150,14 +158,23 @@ export function resolveEventSubject(
|
|
|
150
158
|
|
|
151
159
|
if (normalized.kind === "user") {
|
|
152
160
|
const userId = nonEmptyString(payload[normalized.ownerField]);
|
|
153
|
-
|
|
161
|
+
if (userId !== null) return { kind: "user", userId };
|
|
162
|
+
if (normalized.whenAbsent === "tenant") return resolveEnvelopeTenant(fieldName, envelope);
|
|
163
|
+
if (normalized.whenAbsent === "plaintext") {
|
|
164
|
+
// skip: author declared whenAbsent: "plaintext" — value ships unencrypted by decision
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
throw new SubjectResolutionError(
|
|
168
|
+
fieldName,
|
|
169
|
+
`owner field "${normalized.ownerField}" carries no id and the event declares no whenAbsent fallback — ` +
|
|
170
|
+
`refusing to append plaintext PII. Declare { personal: { of: "${normalized.ownerField}", ` +
|
|
171
|
+
`whenAbsent: "tenant" } } to encrypt under the envelope tenant key, or whenAbsent: "plaintext" ` +
|
|
172
|
+
"to acknowledge that this value cannot be crypto-shredded (fw#2776).",
|
|
173
|
+
);
|
|
154
174
|
}
|
|
155
175
|
|
|
156
176
|
if (normalized.kind === "tenant") {
|
|
157
|
-
|
|
158
|
-
throw new SubjectResolutionError(fieldName, "event envelope tenantId is empty");
|
|
159
|
-
}
|
|
160
|
-
return { kind: "tenant", tenantId: envelope.tenantId };
|
|
177
|
+
return resolveEnvelopeTenant(fieldName, envelope);
|
|
161
178
|
}
|
|
162
179
|
|
|
163
180
|
if (normalized.kind === "self") {
|
|
@@ -37,7 +37,7 @@ const personEntity = createEntity({
|
|
|
37
37
|
table: "read_bidx_persons",
|
|
38
38
|
fields: {
|
|
39
39
|
email: createTextField({ required: true, personal: "self", find: "exact" }),
|
|
40
|
-
firstName: createTextField(),
|
|
40
|
+
firstName: createTextField({ personal: false, reason: "test_fixture" }),
|
|
41
41
|
},
|
|
42
42
|
});
|
|
43
43
|
const personFeature = defineFeature("bidxtest", (r) => {
|
|
@@ -50,7 +50,12 @@ const contactEntity = createEntity({
|
|
|
50
50
|
fields: {
|
|
51
51
|
name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
52
52
|
email: createTextField({ required: true, personal: "tenant", find: "none" }),
|
|
53
|
-
iban: createTextField({
|
|
53
|
+
iban: createTextField({
|
|
54
|
+
personal: false,
|
|
55
|
+
reason: "test_fixture",
|
|
56
|
+
required: true,
|
|
57
|
+
encrypted: true,
|
|
58
|
+
}),
|
|
54
59
|
},
|
|
55
60
|
});
|
|
56
61
|
const leadEntity = createEntity({
|
|
@@ -72,7 +77,12 @@ const ownedContactEntity = createEntity({
|
|
|
72
77
|
fields: {
|
|
73
78
|
name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
74
79
|
email: createTextField({ required: true, personal: "tenant", find: "none" }),
|
|
75
|
-
iban: createTextField({
|
|
80
|
+
iban: createTextField({
|
|
81
|
+
personal: false,
|
|
82
|
+
reason: "test_fixture",
|
|
83
|
+
required: true,
|
|
84
|
+
encrypted: true,
|
|
85
|
+
}),
|
|
76
86
|
},
|
|
77
87
|
access: { read: { admin: from("user:id", "ownerId") } },
|
|
78
88
|
});
|
|
@@ -13,8 +13,8 @@ describe("entity-field-encryption", () => {
|
|
|
13
13
|
const entity = createEntity({
|
|
14
14
|
table: "read_enc_test",
|
|
15
15
|
fields: {
|
|
16
|
-
email: createTextField({ required: true }),
|
|
17
|
-
secretNote: createTextField({ encrypted: true }),
|
|
16
|
+
email: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
17
|
+
secretNote: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
|
|
18
18
|
},
|
|
19
19
|
});
|
|
20
20
|
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
@@ -16,7 +16,7 @@ describe("event-store-executor-context — encryptForStorage/decryptForRead laye
|
|
|
16
16
|
const entity = createEntity({
|
|
17
17
|
table: "pii_roundtrip_test",
|
|
18
18
|
fields: {
|
|
19
|
-
userId: createTextField({ required: true }),
|
|
19
|
+
userId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
20
20
|
// Both markers at once — the auth-mfa.totpSecret/recoveryCodes shape
|
|
21
21
|
// that first surfaced the ordering bug (pii-subject-encryption
|
|
22
22
|
// integration test).
|
|
@@ -34,9 +34,14 @@ const cipher = createTestEnvelopeCipher(TEST_KEY);
|
|
|
34
34
|
const entity = createEntity({
|
|
35
35
|
table: "read_money_orders",
|
|
36
36
|
fields: {
|
|
37
|
-
ownerId: createTextField({ required: true }),
|
|
37
|
+
ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
38
38
|
grossTotal: createMoneyField(),
|
|
39
|
-
billingIban: createTextField({
|
|
39
|
+
billingIban: createTextField({
|
|
40
|
+
personal: false,
|
|
41
|
+
reason: "test_fixture",
|
|
42
|
+
required: true,
|
|
43
|
+
encrypted: true,
|
|
44
|
+
}),
|
|
40
45
|
},
|
|
41
46
|
// A non-"all" read rule forces buildOwnershipClause into the
|
|
42
47
|
// ownership.kind==="sql" raw-SQL branch that list()/detail() read through.
|
|
@@ -227,8 +227,10 @@ describe("event-store-executor write-verbs — restore without softDelete", () =
|
|
|
227
227
|
const ownedFieldEntity = createEntity({
|
|
228
228
|
table: "read_es_write_owned_field",
|
|
229
229
|
fields: {
|
|
230
|
-
authorId: createTextField({ required: true }),
|
|
230
|
+
authorId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
231
231
|
note: createTextField({
|
|
232
|
+
personal: false,
|
|
233
|
+
reason: "test_fixture",
|
|
232
234
|
access: { write: { Admin: "all", User: from("user:id", "authorId") } },
|
|
233
235
|
}),
|
|
234
236
|
},
|
|
@@ -139,8 +139,18 @@ const sensitiveEntity = createEntity({
|
|
|
139
139
|
table: "read_es_exec_sensitive",
|
|
140
140
|
fields: {
|
|
141
141
|
email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
142
|
-
passwordHash: createTextField({
|
|
143
|
-
|
|
142
|
+
passwordHash: createTextField({
|
|
143
|
+
personal: false,
|
|
144
|
+
reason: "test_fixture",
|
|
145
|
+
sensitive: true,
|
|
146
|
+
encrypted: true,
|
|
147
|
+
}),
|
|
148
|
+
apiToken: createTextField({
|
|
149
|
+
personal: false,
|
|
150
|
+
reason: "test_fixture",
|
|
151
|
+
sensitive: true,
|
|
152
|
+
encrypted: true,
|
|
153
|
+
}),
|
|
144
154
|
},
|
|
145
155
|
softDelete: true,
|
|
146
156
|
});
|
|
@@ -343,7 +353,7 @@ const encryptedEntity = createEntity({
|
|
|
343
353
|
table: "read_es_exec_encrypted",
|
|
344
354
|
fields: {
|
|
345
355
|
email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
346
|
-
secretNote: createTextField({ encrypted: true }),
|
|
356
|
+
secretNote: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
|
|
347
357
|
},
|
|
348
358
|
});
|
|
349
359
|
const encryptedTable = buildEntityTable("esExecEncrypted", encryptedEntity);
|
|
@@ -352,7 +362,7 @@ const encryptedSoftDeleteEntity = createEntity({
|
|
|
352
362
|
table: "read_es_exec_enc_soft",
|
|
353
363
|
fields: {
|
|
354
364
|
email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
355
|
-
secretNote: createTextField({ encrypted: true }),
|
|
365
|
+
secretNote: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
|
|
356
366
|
},
|
|
357
367
|
softDelete: true,
|
|
358
368
|
});
|
|
@@ -703,7 +713,7 @@ const piiEntity = createEntity({
|
|
|
703
713
|
personal: { of: "authorId" },
|
|
704
714
|
find: "none",
|
|
705
715
|
}),
|
|
706
|
-
authorId: createTextField(),
|
|
716
|
+
authorId: createTextField({ personal: false, reason: "test_fixture" }),
|
|
707
717
|
plain: createTextField({ personal: false, reason: "test_fixture" }),
|
|
708
718
|
},
|
|
709
719
|
});
|
|
@@ -17,14 +17,18 @@ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } fr
|
|
|
17
17
|
const filterNoteEntity = createEntity({
|
|
18
18
|
table: "fa_filter_notes",
|
|
19
19
|
fields: {
|
|
20
|
-
title: createTextField({ required: true }),
|
|
21
|
-
ownerId: createTextField({ required: true }),
|
|
20
|
+
title: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
21
|
+
ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
22
22
|
secret: createTextField({
|
|
23
|
+
personal: false,
|
|
24
|
+
reason: "test_fixture",
|
|
23
25
|
filterable: true,
|
|
24
26
|
sortable: true,
|
|
25
27
|
access: { read: { Admin: "all" } },
|
|
26
28
|
}),
|
|
27
29
|
ownedNote: createTextField({
|
|
30
|
+
personal: false,
|
|
31
|
+
reason: "test_fixture",
|
|
28
32
|
filterable: true,
|
|
29
33
|
access: { read: { Admin: "all", User: from("user:id", "ownerId") } },
|
|
30
34
|
}),
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// expressed in meta (hand-added in a migration) is not reconstructed, and a
|
|
19
19
|
// partial index whose WHERE the renderer can't express is rejected up-front.
|
|
20
20
|
|
|
21
|
+
import { configuredBlindIndexKey } from "../../crypto";
|
|
21
22
|
import type { DbConnection, DbTx } from "../connection";
|
|
22
23
|
import type { EntityTableMeta } from "../entity-table-meta";
|
|
23
24
|
import { type AnyDb, asEntityTableMeta, asRawClient } from "../query";
|
|
@@ -247,6 +248,40 @@ export async function assertNoUnreachableLiveRows(
|
|
|
247
248
|
);
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
// The bidx column is schema-driven, not key-driven: it exists NULL in a
|
|
252
|
+
// plaintext install and in the fw#1610 case (KMS configured, no index key),
|
|
253
|
+
// both of which are correct as-is. Only a POPULATED column with no key
|
|
254
|
+
// configured in THIS process means the rebuild is about to overwrite proof
|
|
255
|
+
// of a real index with NULL — that's the one provable data-loss case.
|
|
256
|
+
export async function assertNoBlindIndexLoss(
|
|
257
|
+
tx: AnyDb,
|
|
258
|
+
tableName: string,
|
|
259
|
+
meta: EntityTableMeta,
|
|
260
|
+
projectionName: string,
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
// skip: a key is configured — the replay recomputes every bidx column with it
|
|
263
|
+
if (configuredBlindIndexKey() !== undefined) return;
|
|
264
|
+
const bidxCols = meta.columns.filter((c) => c.name.endsWith("_bidx"));
|
|
265
|
+
// skip: no blind-index column on this table — nothing the rebuild could lose
|
|
266
|
+
if (bidxCols.length === 0) return;
|
|
267
|
+
const t = quoteTableIdent(tableName);
|
|
268
|
+
const raw = asRawClient(tx);
|
|
269
|
+
const where = bidxCols.map((c) => `${quoteTableIdent(c.name)} IS NOT NULL`).join(" OR ");
|
|
270
|
+
const rows = await raw.unsafe<{ total: string }>(
|
|
271
|
+
`SELECT count(*)::text AS total FROM public.${t} WHERE ${where}`,
|
|
272
|
+
);
|
|
273
|
+
const count = Number(rows[0]?.total ?? "0");
|
|
274
|
+
// skip: every bidx column is already NULL — nothing for the rebuild to lose
|
|
275
|
+
if (count === 0) return;
|
|
276
|
+
throw new Error(
|
|
277
|
+
`projection-rebuild "${projectionName}": "${tableName}" has ${count} row(s) with a populated ` +
|
|
278
|
+
`blind-index column, but KUMIKO_BLIND_INDEX_KEY is not configured in this process. The rebuild ` +
|
|
279
|
+
`would recompute those columns to NULL, and equality lookups on that field (login, password ` +
|
|
280
|
+
`reset) would stop matching afterward. Configure the blind-index key before this apply/rebuild ` +
|
|
281
|
+
`runs. See fw#3091. Rebuild aborted; live table untouched.`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
250
285
|
// Columns ignored by countColumnDrift — the one PROVABLY legitimate class of
|
|
251
286
|
// live-vs-shadow divergence. A blind-index column (`<field>_bidx`) is
|
|
252
287
|
// recomputed to NULL on GDPR key-shredding; the NULL is the intended end
|
|
@@ -229,6 +229,38 @@ describe("validateBoot — action wiring (no function values)", () => {
|
|
|
229
229
|
expect(() => validateBoot([feature])).toThrow(/field "name" renderer is a function/);
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
test("entityEdit field renderer as function inside groups → Throw", () => {
|
|
233
|
+
const feature = defineFeature("shop", (r) => {
|
|
234
|
+
r.entity(
|
|
235
|
+
"product",
|
|
236
|
+
createEntity({
|
|
237
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
r.screen({
|
|
241
|
+
id: "product-edit",
|
|
242
|
+
type: "entityEdit",
|
|
243
|
+
entity: "product",
|
|
244
|
+
layout: {
|
|
245
|
+
sections: [
|
|
246
|
+
{
|
|
247
|
+
columns: 1,
|
|
248
|
+
fields: [],
|
|
249
|
+
groups: [
|
|
250
|
+
{
|
|
251
|
+
title: "Basis",
|
|
252
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
253
|
+
fields: [{ field: "name", renderer: ((v: unknown) => String(v)) as any }],
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
expect(() => validateBoot([feature])).toThrow(/field "name" renderer is a function/);
|
|
262
|
+
});
|
|
263
|
+
|
|
232
264
|
test("entityEdit field with declarative visible/readOnly/required → kein Throw", () => {
|
|
233
265
|
const feature = defineFeature("shop", (r) => {
|
|
234
266
|
r.entity(
|