@cosmicdrift/kumiko-framework 0.290.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__/pii-personal-migration-report-codemod.test.ts +160 -0
- package/src/api/__tests__/server-error-logging.test.ts +168 -17
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -1
- package/src/api/routes.ts +35 -3
- package/src/changes.json +51 -0
- package/src/crypto/__tests__/event-pii.test.ts +110 -9
- package/src/crypto/__tests__/subject-resolver.test.ts +23 -2
- package/src/crypto/subject-resolver.ts +25 -8
- package/src/db/queries/shadow-swap.ts +35 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +122 -0
- 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__/form-money-currency-types.test.ts +90 -0
- 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 +50 -0
- package/src/engine/create-app.ts +54 -0
- package/src/engine/feature-config-events-jobs.ts +19 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/screen-helpers.ts +1 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +30 -6
- 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 +94 -4
- 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/projection-rebuild.ts +7 -0
- package/src/schema-cli.ts +21 -0
- package/src/scripts/codemod/pii-personal-migration.ts +242 -2
|
@@ -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());
|
|
@@ -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") {
|
|
@@ -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
|
|
@@ -603,6 +603,128 @@ describe("validateBoot — PII annotations", () => {
|
|
|
603
603
|
);
|
|
604
604
|
expect(matchingWarn).toBeDefined();
|
|
605
605
|
});
|
|
606
|
+
|
|
607
|
+
// --- #2918: deprecation warning for text fields without any stance ---
|
|
608
|
+
|
|
609
|
+
const NO_STANCE = "declares no personal stance";
|
|
610
|
+
|
|
611
|
+
test("text field without a personal stance warns, naming feature, entity and field", () => {
|
|
612
|
+
const feature = defineFeature("test", (r) => {
|
|
613
|
+
r.entity(
|
|
614
|
+
"invoice",
|
|
615
|
+
createEntity({
|
|
616
|
+
fields: {
|
|
617
|
+
externalRef: { ...unannotatedText },
|
|
618
|
+
},
|
|
619
|
+
}),
|
|
620
|
+
);
|
|
621
|
+
});
|
|
622
|
+
validateBoot([feature]);
|
|
623
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
624
|
+
String(args[0]).includes(NO_STANCE),
|
|
625
|
+
);
|
|
626
|
+
expect(matchingWarn).toBeDefined();
|
|
627
|
+
const message = String((matchingWarn as unknown[])[0]);
|
|
628
|
+
expect(message).toContain("[Feature test]");
|
|
629
|
+
expect(message).toContain('Field "externalRef" on entity "invoice"');
|
|
630
|
+
expect(message).toContain('{ reason: "..." }');
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("longText field without a personal stance warns", () => {
|
|
634
|
+
const feature = defineFeature("test", (r) => {
|
|
635
|
+
r.entity(
|
|
636
|
+
"invoice",
|
|
637
|
+
createEntity({
|
|
638
|
+
fields: {
|
|
639
|
+
remarks: { ...unannotatedLongText },
|
|
640
|
+
},
|
|
641
|
+
}),
|
|
642
|
+
);
|
|
643
|
+
});
|
|
644
|
+
validateBoot([feature]);
|
|
645
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
646
|
+
String(args[0]).includes(NO_STANCE),
|
|
647
|
+
);
|
|
648
|
+
expect(matchingWarn).toBeDefined();
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("personal: false with a reason silences the stance warning", () => {
|
|
652
|
+
const feature = defineFeature("test", (r) => {
|
|
653
|
+
r.entity(
|
|
654
|
+
"invoice",
|
|
655
|
+
createEntity({
|
|
656
|
+
fields: {
|
|
657
|
+
externalRef: createTextField({
|
|
658
|
+
personal: false,
|
|
659
|
+
reason: "is_business_data",
|
|
660
|
+
}),
|
|
661
|
+
},
|
|
662
|
+
}),
|
|
663
|
+
);
|
|
664
|
+
});
|
|
665
|
+
validateBoot([feature]);
|
|
666
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
667
|
+
String(args[0]).includes(NO_STANCE),
|
|
668
|
+
);
|
|
669
|
+
expect(matchingWarn).toBeUndefined();
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("personal: self silences the stance warning", () => {
|
|
673
|
+
const feature = defineFeature("test", (r) => {
|
|
674
|
+
r.entity(
|
|
675
|
+
"invoice",
|
|
676
|
+
createEntity({
|
|
677
|
+
fields: {
|
|
678
|
+
externalRef: createTextField({
|
|
679
|
+
personal: "self",
|
|
680
|
+
find: "none",
|
|
681
|
+
}),
|
|
682
|
+
},
|
|
683
|
+
}),
|
|
684
|
+
);
|
|
685
|
+
});
|
|
686
|
+
validateBoot([feature]);
|
|
687
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
688
|
+
String(args[0]).includes(NO_STANCE),
|
|
689
|
+
);
|
|
690
|
+
expect(matchingWarn).toBeUndefined();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("personal: ref silences the stance warning", () => {
|
|
694
|
+
const feature = defineFeature("test", (r) => {
|
|
695
|
+
r.entity(
|
|
696
|
+
"invoice",
|
|
697
|
+
createEntity({
|
|
698
|
+
fields: {
|
|
699
|
+
ownerRef: createTextField({ personal: "ref" }),
|
|
700
|
+
},
|
|
701
|
+
}),
|
|
702
|
+
);
|
|
703
|
+
});
|
|
704
|
+
validateBoot([feature]);
|
|
705
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
706
|
+
String(args[0]).includes(NO_STANCE),
|
|
707
|
+
);
|
|
708
|
+
expect(matchingWarn).toBeUndefined();
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("number field without a personal stance does not warn", () => {
|
|
712
|
+
const feature = defineFeature("test", (r) => {
|
|
713
|
+
r.entity(
|
|
714
|
+
"invoice",
|
|
715
|
+
createEntity({
|
|
716
|
+
fields: {
|
|
717
|
+
total: { type: "number", required: false },
|
|
718
|
+
},
|
|
719
|
+
}),
|
|
720
|
+
);
|
|
721
|
+
});
|
|
722
|
+
validateBoot([feature]);
|
|
723
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
724
|
+
String(args[0]).includes(NO_STANCE),
|
|
725
|
+
);
|
|
726
|
+
expect(matchingWarn).toBeUndefined();
|
|
727
|
+
});
|
|
606
728
|
});
|
|
607
729
|
|
|
608
730
|
describe("validateBoot — retention", () => {
|
|
@@ -2506,6 +2506,116 @@ describe("boot-validator", () => {
|
|
|
2506
2506
|
);
|
|
2507
2507
|
});
|
|
2508
2508
|
|
|
2509
|
+
// fw#2839: an actionForm has no entity, so a money field there can't
|
|
2510
|
+
// inherit entity.defaultCurrency — undeclared, the renderer seeds a bare
|
|
2511
|
+
// `0` and the handler's zod schema rejects the submit.
|
|
2512
|
+
describe("money field currency source (fw#2839)", () => {
|
|
2513
|
+
const moneyForm = (currency?: unknown) =>
|
|
2514
|
+
makeFeature({
|
|
2515
|
+
fields: { amount: { type: "money", ...(currency !== undefined && { currency }) } },
|
|
2516
|
+
sections: [{ title: "Payment", fields: ["amount"] }],
|
|
2517
|
+
});
|
|
2518
|
+
|
|
2519
|
+
test("money field ohne currency → Throw, nennt Screen und Feld", () => {
|
|
2520
|
+
expect(() => validateBoot([moneyForm()])).toThrow(
|
|
2521
|
+
/Screen "approve-invoice" \(actionForm\) money field "amount" must declare where its currency comes from/,
|
|
2522
|
+
);
|
|
2523
|
+
});
|
|
2524
|
+
|
|
2525
|
+
test("Fehlermeldung nennt beide gültigen Formen wörtlich", () => {
|
|
2526
|
+
expect(() => validateBoot([moneyForm()])).toThrow(
|
|
2527
|
+
/currency: \{ kind: "literal", code: "EUR" \}[\s\S]*currency: \{ kind: "tenant" \}/,
|
|
2528
|
+
);
|
|
2529
|
+
});
|
|
2530
|
+
|
|
2531
|
+
test("currency: { kind: 'literal', code } → kein Throw", () => {
|
|
2532
|
+
expect(() => validateBoot([moneyForm({ kind: "literal", code: "EUR" })])).not.toThrow();
|
|
2533
|
+
});
|
|
2534
|
+
|
|
2535
|
+
test("currency: { kind: 'tenant' } → kein Throw", () => {
|
|
2536
|
+
expect(() => validateBoot([moneyForm({ kind: "tenant" })])).not.toThrow();
|
|
2537
|
+
});
|
|
2538
|
+
|
|
2539
|
+
test("literal mit leerem code → Throw", () => {
|
|
2540
|
+
expect(() => validateBoot([moneyForm({ kind: "literal", code: " " })])).toThrow(
|
|
2541
|
+
/empty or non-string `code`/,
|
|
2542
|
+
);
|
|
2543
|
+
});
|
|
2544
|
+
|
|
2545
|
+
test("unbekannter kind → Throw", () => {
|
|
2546
|
+
expect(() => validateBoot([moneyForm({ kind: "entityDefault" })])).toThrow(
|
|
2547
|
+
/unknown currency kind "entityDefault"/,
|
|
2548
|
+
);
|
|
2549
|
+
});
|
|
2550
|
+
|
|
2551
|
+
test("non-money Felder bleiben unberührt", () => {
|
|
2552
|
+
expect(() => validateBoot([makeFeature()])).not.toThrow();
|
|
2553
|
+
});
|
|
2554
|
+
|
|
2555
|
+
// The check sits in validateFormFieldsMap, the walk actionForm,
|
|
2556
|
+
// secretMint and secretMint's confirm step all share.
|
|
2557
|
+
function mintFeature(
|
|
2558
|
+
fields: Record<string, unknown>,
|
|
2559
|
+
confirmFields?: Record<string, unknown>,
|
|
2560
|
+
) {
|
|
2561
|
+
return defineFeature("shop", (r) => {
|
|
2562
|
+
r.writeHandler({
|
|
2563
|
+
name: "token:mint",
|
|
2564
|
+
schema: { _type: "stub" } as never,
|
|
2565
|
+
handler: async () => ({ isSuccess: true, data: {} }) as never,
|
|
2566
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
2567
|
+
});
|
|
2568
|
+
r.screen({
|
|
2569
|
+
id: "mint-token",
|
|
2570
|
+
type: "secretMint",
|
|
2571
|
+
handler: "shop:write:token:mint",
|
|
2572
|
+
fields: fields as never,
|
|
2573
|
+
layout: { sections: [{ title: "Mint", fields: Object.keys(fields) }] as never },
|
|
2574
|
+
reveal: { fields: [{ field: "token", label: "Token" }] },
|
|
2575
|
+
...(confirmFields !== undefined && {
|
|
2576
|
+
confirm: {
|
|
2577
|
+
handler: "shop:write:token:mint",
|
|
2578
|
+
fields: confirmFields as never,
|
|
2579
|
+
layout: {
|
|
2580
|
+
sections: [{ title: "Confirm", fields: Object.keys(confirmFields) }] as never,
|
|
2581
|
+
},
|
|
2582
|
+
},
|
|
2583
|
+
}),
|
|
2584
|
+
});
|
|
2585
|
+
});
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
test("secretMint money field ohne currency → Throw", () => {
|
|
2589
|
+
expect(() => validateBoot([mintFeature({ fee: { type: "money" } })])).toThrow(
|
|
2590
|
+
/Screen "mint-token" \(secretMint\) money field "fee" must declare where its currency comes from/,
|
|
2591
|
+
);
|
|
2592
|
+
});
|
|
2593
|
+
|
|
2594
|
+
test("secretMint confirm-Step money field ohne currency → Throw", () => {
|
|
2595
|
+
expect(() =>
|
|
2596
|
+
validateBoot([
|
|
2597
|
+
mintFeature(
|
|
2598
|
+
{ fee: { type: "money", currency: { kind: "literal", code: "EUR" } } },
|
|
2599
|
+
{ topUp: { type: "money" } },
|
|
2600
|
+
),
|
|
2601
|
+
]),
|
|
2602
|
+
).toThrow(
|
|
2603
|
+
/Screen "mint-token" \(secretMint confirm\) money field "topUp" must declare where its currency comes from/,
|
|
2604
|
+
);
|
|
2605
|
+
});
|
|
2606
|
+
|
|
2607
|
+
test("secretMint mit deklarierten Quellen in beiden Steps → kein Throw", () => {
|
|
2608
|
+
expect(() =>
|
|
2609
|
+
validateBoot([
|
|
2610
|
+
mintFeature(
|
|
2611
|
+
{ fee: { type: "money", currency: { kind: "literal", code: "EUR" } } },
|
|
2612
|
+
{ topUp: { type: "money", currency: { kind: "tenant" } } },
|
|
2613
|
+
),
|
|
2614
|
+
]),
|
|
2615
|
+
).not.toThrow();
|
|
2616
|
+
});
|
|
2617
|
+
});
|
|
2618
|
+
|
|
2509
2619
|
test("layout.sections leer → Throw", () => {
|
|
2510
2620
|
expect(() => validateBoot([makeFeature({ sections: [] })])).toThrow(
|
|
2511
2621
|
/has an empty sections list/,
|
|
@@ -3846,6 +3956,122 @@ describe("boot-validator", () => {
|
|
|
3846
3956
|
);
|
|
3847
3957
|
});
|
|
3848
3958
|
|
|
3959
|
+
test("reference optionsQuery naming a registered handler → kein Throw (fw#2780)", () => {
|
|
3960
|
+
const features = [
|
|
3961
|
+
defineFeature("shop", (r) => {
|
|
3962
|
+
r.entity(
|
|
3963
|
+
"customer",
|
|
3964
|
+
createEntity({
|
|
3965
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
3966
|
+
}),
|
|
3967
|
+
);
|
|
3968
|
+
stubListHandler(r, "customer");
|
|
3969
|
+
r.queryHandler({
|
|
3970
|
+
name: "customer:options",
|
|
3971
|
+
schema: z.object({}),
|
|
3972
|
+
handler: async () => ({ rows: [] }) as never,
|
|
3973
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
3974
|
+
});
|
|
3975
|
+
r.entity(
|
|
3976
|
+
"order",
|
|
3977
|
+
createEntity({
|
|
3978
|
+
fields: {
|
|
3979
|
+
customerId: {
|
|
3980
|
+
type: "reference",
|
|
3981
|
+
entity: "customer",
|
|
3982
|
+
optionsQuery: "shop:query:customer:options",
|
|
3983
|
+
},
|
|
3984
|
+
},
|
|
3985
|
+
}),
|
|
3986
|
+
);
|
|
3987
|
+
}),
|
|
3988
|
+
];
|
|
3989
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
3990
|
+
});
|
|
3991
|
+
|
|
3992
|
+
test("reference optionsQuery auf unregistrierten Handler → Throw (fw#2780)", () => {
|
|
3993
|
+
const features = [
|
|
3994
|
+
defineFeature("shop", (r) => {
|
|
3995
|
+
r.entity(
|
|
3996
|
+
"customer",
|
|
3997
|
+
createEntity({
|
|
3998
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
3999
|
+
}),
|
|
4000
|
+
);
|
|
4001
|
+
stubListHandler(r, "customer");
|
|
4002
|
+
r.entity(
|
|
4003
|
+
"order",
|
|
4004
|
+
createEntity({
|
|
4005
|
+
fields: {
|
|
4006
|
+
customerId: {
|
|
4007
|
+
type: "reference",
|
|
4008
|
+
entity: "customer",
|
|
4009
|
+
optionsQuery: "shop:query:customer:typo",
|
|
4010
|
+
},
|
|
4011
|
+
},
|
|
4012
|
+
}),
|
|
4013
|
+
);
|
|
4014
|
+
}),
|
|
4015
|
+
];
|
|
4016
|
+
expect(() => validateBoot(features)).toThrow(
|
|
4017
|
+
/Reference field "customerId" on entity "order" declares optionsQuery "shop:query:customer:typo" which is not a registered query-handler/,
|
|
4018
|
+
);
|
|
4019
|
+
});
|
|
4020
|
+
|
|
4021
|
+
test("leerer optionsQuery → Throw (fw#2780)", () => {
|
|
4022
|
+
const features = [
|
|
4023
|
+
defineFeature("shop", (r) => {
|
|
4024
|
+
r.entity(
|
|
4025
|
+
"customer",
|
|
4026
|
+
createEntity({
|
|
4027
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
4028
|
+
}),
|
|
4029
|
+
);
|
|
4030
|
+
stubListHandler(r, "customer");
|
|
4031
|
+
r.entity(
|
|
4032
|
+
"order",
|
|
4033
|
+
createEntity({
|
|
4034
|
+
fields: {
|
|
4035
|
+
customerId: { type: "reference", entity: "customer", optionsQuery: "" },
|
|
4036
|
+
},
|
|
4037
|
+
}),
|
|
4038
|
+
);
|
|
4039
|
+
}),
|
|
4040
|
+
];
|
|
4041
|
+
expect(() => validateBoot(features)).toThrow(/has an empty optionsQuery/);
|
|
4042
|
+
});
|
|
4043
|
+
|
|
4044
|
+
test("reference sub-field optionsQuery auf unregistrierten Handler → Throw mit parent.child-Pfad (fw#2780)", () => {
|
|
4045
|
+
const features = [
|
|
4046
|
+
defineFeature("shop", (r) => {
|
|
4047
|
+
r.entity(
|
|
4048
|
+
"product",
|
|
4049
|
+
createEntity({
|
|
4050
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
4051
|
+
}),
|
|
4052
|
+
);
|
|
4053
|
+
stubListHandler(r, "product");
|
|
4054
|
+
r.entity(
|
|
4055
|
+
"invoice",
|
|
4056
|
+
createEntity({
|
|
4057
|
+
fields: {
|
|
4058
|
+
lines: createEmbeddedListField({
|
|
4059
|
+
productId: {
|
|
4060
|
+
type: "reference",
|
|
4061
|
+
entity: "product",
|
|
4062
|
+
optionsQuery: "shop:query:product:typo",
|
|
4063
|
+
},
|
|
4064
|
+
}),
|
|
4065
|
+
},
|
|
4066
|
+
}),
|
|
4067
|
+
);
|
|
4068
|
+
}),
|
|
4069
|
+
];
|
|
4070
|
+
expect(() => validateBoot(features)).toThrow(
|
|
4071
|
+
/Reference field "lines\.productId" on entity "invoice" declares optionsQuery "shop:query:product:typo"/,
|
|
4072
|
+
);
|
|
4073
|
+
});
|
|
4074
|
+
|
|
3849
4075
|
test("reference sub-field labelField referencing an unknown field → Throw with the parent.child field path", () => {
|
|
3850
4076
|
const features = [
|
|
3851
4077
|
defineFeature("shop", (r) => {
|