@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
|
@@ -62,7 +62,7 @@ describe("r.bootCheck / validateFeatureBootChecks", () => {
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
test("no PII field → boot succeeds even without the companion", () => {
|
|
65
|
-
const noPiiFields = { text: createTextField() };
|
|
65
|
+
const noPiiFields = { text: createTextField({ personal: false, reason: "test_fixture" }) };
|
|
66
66
|
const noPii = defineFeature("prompt-store-no-pii", (r) => {
|
|
67
67
|
r.entity("note", createEntity({ fields: noPiiFields }));
|
|
68
68
|
r.bootCheck(({ features }) => {
|
|
@@ -40,6 +40,17 @@ import type { LongTextFieldDef, TextFieldDef } from "../types";
|
|
|
40
40
|
const rawField = <T>(f: T) => f as unknown as TextFieldDef;
|
|
41
41
|
const rawLongTextField = <T>(f: T) => f as unknown as LongTextFieldDef;
|
|
42
42
|
|
|
43
|
+
// The heuristics under test only fire without a personal stance; after #2810
|
|
44
|
+
// the factories can no longer produce that shape.
|
|
45
|
+
const unannotatedText: TextFieldDef = {
|
|
46
|
+
type: "text",
|
|
47
|
+
maxLength: 200,
|
|
48
|
+
required: false,
|
|
49
|
+
searchable: false,
|
|
50
|
+
sortable: false,
|
|
51
|
+
};
|
|
52
|
+
const unannotatedLongText: LongTextFieldDef = { type: "longText", required: false };
|
|
53
|
+
|
|
43
54
|
// Stubt einen leeren `<entity>:list`-Query-Handler damit der reference-
|
|
44
55
|
// Field-Boot-Validator den Audit-Fix-#2-Check durchläßt. Wird gebraucht
|
|
45
56
|
// wenn ein Test ein reference-Feld benutzt — sonst liefert der validator
|
|
@@ -136,7 +147,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
136
147
|
createEntity({
|
|
137
148
|
fields: {
|
|
138
149
|
confused: rawField({
|
|
139
|
-
...
|
|
150
|
+
...unannotatedText,
|
|
140
151
|
pii: true,
|
|
141
152
|
tenantOwned: true,
|
|
142
153
|
}),
|
|
@@ -153,7 +164,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
153
164
|
"vault",
|
|
154
165
|
createEntity({
|
|
155
166
|
fields: {
|
|
156
|
-
apiToken:
|
|
167
|
+
apiToken: { ...unannotatedText, sensitive: true },
|
|
157
168
|
},
|
|
158
169
|
}),
|
|
159
170
|
);
|
|
@@ -188,7 +199,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
188
199
|
"vault",
|
|
189
200
|
createEntity({
|
|
190
201
|
fields: {
|
|
191
|
-
apiToken:
|
|
202
|
+
apiToken: { ...unannotatedText, sensitive: true, encrypted: true },
|
|
192
203
|
},
|
|
193
204
|
}),
|
|
194
205
|
);
|
|
@@ -228,7 +239,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
228
239
|
personal: { of: "authorId" },
|
|
229
240
|
find: "none",
|
|
230
241
|
}),
|
|
231
|
-
authorId:
|
|
242
|
+
authorId: { ...unannotatedText },
|
|
232
243
|
},
|
|
233
244
|
}),
|
|
234
245
|
);
|
|
@@ -258,7 +269,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
258
269
|
|
|
259
270
|
test("userOwned.ownerField referencing non-user entity warns", () => {
|
|
260
271
|
const feature = defineFeature("test", (r) => {
|
|
261
|
-
r.entity("employee", createEntity({ fields: { name:
|
|
272
|
+
r.entity("employee", createEntity({ fields: { name: { ...unannotatedText } } }));
|
|
262
273
|
stubListHandler(r, "employee");
|
|
263
274
|
r.entity(
|
|
264
275
|
"personalNote",
|
|
@@ -286,7 +297,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
286
297
|
"thing",
|
|
287
298
|
createEntity({
|
|
288
299
|
fields: {
|
|
289
|
-
email:
|
|
300
|
+
email: { ...unannotatedText },
|
|
290
301
|
},
|
|
291
302
|
}),
|
|
292
303
|
);
|
|
@@ -304,7 +315,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
304
315
|
"thing",
|
|
305
316
|
createEntity({
|
|
306
317
|
fields: {
|
|
307
|
-
body:
|
|
318
|
+
body: { ...unannotatedLongText },
|
|
308
319
|
},
|
|
309
320
|
}),
|
|
310
321
|
);
|
|
@@ -322,7 +333,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
322
333
|
"thing",
|
|
323
334
|
createEntity({
|
|
324
335
|
fields: {
|
|
325
|
-
authorId:
|
|
336
|
+
authorId: { ...unannotatedText },
|
|
326
337
|
},
|
|
327
338
|
}),
|
|
328
339
|
);
|
|
@@ -563,7 +574,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
563
574
|
"product",
|
|
564
575
|
createEntity({
|
|
565
576
|
fields: {
|
|
566
|
-
name:
|
|
577
|
+
name: { ...unannotatedText },
|
|
567
578
|
},
|
|
568
579
|
}),
|
|
569
580
|
);
|
|
@@ -581,7 +592,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
581
592
|
"person",
|
|
582
593
|
createEntity({
|
|
583
594
|
fields: {
|
|
584
|
-
displayName:
|
|
595
|
+
displayName: { ...unannotatedText },
|
|
585
596
|
},
|
|
586
597
|
}),
|
|
587
598
|
);
|
|
@@ -592,6 +603,128 @@ describe("validateBoot — PII annotations", () => {
|
|
|
592
603
|
);
|
|
593
604
|
expect(matchingWarn).toBeDefined();
|
|
594
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
|
+
});
|
|
595
728
|
});
|
|
596
729
|
|
|
597
730
|
describe("validateBoot — retention", () => {
|
|
@@ -626,7 +759,7 @@ describe("validateBoot — retention", () => {
|
|
|
626
759
|
"auditEvent",
|
|
627
760
|
createEntity({
|
|
628
761
|
fields: {
|
|
629
|
-
note:
|
|
762
|
+
note: { ...unannotatedText },
|
|
630
763
|
},
|
|
631
764
|
retention: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
|
|
632
765
|
}),
|
|
@@ -641,7 +774,7 @@ describe("validateBoot — retention", () => {
|
|
|
641
774
|
"thing",
|
|
642
775
|
createEntity({
|
|
643
776
|
fields: {
|
|
644
|
-
note:
|
|
777
|
+
note: { ...unannotatedText },
|
|
645
778
|
},
|
|
646
779
|
retention: { keepFor: "30d", strategy: "hardDelete", reference: "ghostField" },
|
|
647
780
|
}),
|
|
@@ -852,7 +985,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
852
985
|
createEntity({
|
|
853
986
|
fields: {
|
|
854
987
|
slug: rawField({
|
|
855
|
-
...
|
|
988
|
+
...unannotatedText,
|
|
856
989
|
lookupable: true,
|
|
857
990
|
}),
|
|
858
991
|
},
|
|
@@ -872,7 +1005,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
872
1005
|
...createLongTextField({ personal: { of: "ownerId" }, find: "none" }),
|
|
873
1006
|
lookupable: true,
|
|
874
1007
|
}),
|
|
875
|
-
ownerId:
|
|
1008
|
+
ownerId: { ...unannotatedText, required: true },
|
|
876
1009
|
},
|
|
877
1010
|
}),
|
|
878
1011
|
);
|
|
@@ -922,7 +1055,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
922
1055
|
createEntity({
|
|
923
1056
|
fields: {
|
|
924
1057
|
passwordHash: rawField({
|
|
925
|
-
...
|
|
1058
|
+
...unannotatedText,
|
|
926
1059
|
pii: true,
|
|
927
1060
|
sensitive: true,
|
|
928
1061
|
searchable: true,
|
|
@@ -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) => {
|
|
@@ -644,6 +644,24 @@ describe("buildAppSchema", () => {
|
|
|
644
644
|
expect(JSON.parse(JSON.stringify(projected))).toEqual(projected);
|
|
645
645
|
});
|
|
646
646
|
|
|
647
|
+
test("money field's `currency: { kind: 'literal', code }` declaration survives the projection (fw#2839)", () => {
|
|
648
|
+
const entity = {
|
|
649
|
+
defaultCurrency: "USD",
|
|
650
|
+
fields: { price: { type: "money", currency: { kind: "literal", code: "USD" } } },
|
|
651
|
+
} as unknown as EntityDefinition;
|
|
652
|
+
|
|
653
|
+
const f = defineFeature("ent", (r) => {
|
|
654
|
+
r.entity("thing", entity);
|
|
655
|
+
});
|
|
656
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
657
|
+
const projected = app.features[0]!.entities["thing"] as unknown as {
|
|
658
|
+
fields: Record<string, Record<string, unknown>>;
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
expect(projected.fields["price"]?.["currency"]).toEqual({ kind: "literal", code: "USD" });
|
|
662
|
+
expect(JSON.parse(JSON.stringify(projected))).toEqual(projected);
|
|
663
|
+
});
|
|
664
|
+
|
|
647
665
|
test("text format, timestamp locatedBy, file accept/maxSize, image variants and decimal scale survive the projection", () => {
|
|
648
666
|
// Regression: all of these are read by the edit view-model (password
|
|
649
667
|
// masking, wall-clock input, upload constraints, preview variant, derived
|
|
@@ -909,6 +909,45 @@ describe("createRegistry", () => {
|
|
|
909
909
|
]);
|
|
910
910
|
});
|
|
911
911
|
|
|
912
|
+
test("optionsQuery leaves searchable/sortable on the labelField column (fw#2780)", () => {
|
|
913
|
+
const feature = defineFeature("crm", (r) => {
|
|
914
|
+
r.entity(
|
|
915
|
+
"customer",
|
|
916
|
+
createEntity({
|
|
917
|
+
table: "Customers",
|
|
918
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
919
|
+
}),
|
|
920
|
+
);
|
|
921
|
+
r.queryHandler("customer:options", z.object({}), async () => [], {
|
|
922
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
923
|
+
});
|
|
924
|
+
r.entity(
|
|
925
|
+
"order",
|
|
926
|
+
createEntity({
|
|
927
|
+
table: "Orders",
|
|
928
|
+
fields: {
|
|
929
|
+
customerId: {
|
|
930
|
+
type: "reference",
|
|
931
|
+
entity: "customer",
|
|
932
|
+
labelField: "name",
|
|
933
|
+
optionsQuery: "crm:query:customer:options",
|
|
934
|
+
searchable: true,
|
|
935
|
+
sortable: true,
|
|
936
|
+
},
|
|
937
|
+
},
|
|
938
|
+
}),
|
|
939
|
+
);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
const registry = createRegistry([feature]);
|
|
943
|
+
expect(registry.getSortableReferences("order")).toEqual([
|
|
944
|
+
{ fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
|
|
945
|
+
]);
|
|
946
|
+
expect(registry.getSearchableReferences("order")).toEqual([
|
|
947
|
+
{ fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
|
|
948
|
+
]);
|
|
949
|
+
});
|
|
950
|
+
|
|
912
951
|
test("throws at boot when a sortable reference field has no explicit labelField (fw#2741)", () => {
|
|
913
952
|
const feature = defineFeature("crm", (r) => {
|
|
914
953
|
r.entity(
|
|
@@ -1204,6 +1243,54 @@ describe("createApp", () => {
|
|
|
1204
1243
|
);
|
|
1205
1244
|
});
|
|
1206
1245
|
|
|
1246
|
+
// fw#2839: a literal currency source is only meaningful for a code the app
|
|
1247
|
+
// knows — a typo would otherwise render and submit an unformattable amount.
|
|
1248
|
+
function literalCurrencyFeature(code: string) {
|
|
1249
|
+
return defineFeature("test", (r) => {
|
|
1250
|
+
r.entity(
|
|
1251
|
+
"invoice",
|
|
1252
|
+
createEntity({
|
|
1253
|
+
table: "Invoices",
|
|
1254
|
+
defaultCurrency: "EUR",
|
|
1255
|
+
fields: { total: { type: "money", currency: { kind: "literal", code } } },
|
|
1256
|
+
} as never),
|
|
1257
|
+
);
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
test("rejects currency: { kind: 'literal' } with a code outside the currencies list", () => {
|
|
1262
|
+
expect(() =>
|
|
1263
|
+
createApp({ roles: ["Admin"], features: [literalCurrencyFeature("EURO")] }),
|
|
1264
|
+
).toThrow(/code: "EURO" \} which is not in the currencies list/);
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
test("accepts currency: { kind: 'literal' } with a code from the currencies list", () => {
|
|
1268
|
+
expect(() =>
|
|
1269
|
+
createApp({ roles: ["Admin"], features: [literalCurrencyFeature("EUR")] }),
|
|
1270
|
+
).not.toThrow();
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
test("rejects an unknown literal code on an actionForm screen field too", () => {
|
|
1274
|
+
const feature = defineFeature("test", (r) => {
|
|
1275
|
+
r.writeHandler({
|
|
1276
|
+
name: "invoice:pay",
|
|
1277
|
+
schema: { _type: "stub" } as never,
|
|
1278
|
+
handler: async () => ({ isSuccess: true, data: {} }) as never,
|
|
1279
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
1280
|
+
});
|
|
1281
|
+
r.screen({
|
|
1282
|
+
id: "invoice-pay",
|
|
1283
|
+
type: "actionForm",
|
|
1284
|
+
handler: "test:write:invoice:pay",
|
|
1285
|
+
fields: { amount: { type: "money", currency: { kind: "literal", code: "EURO" } } } as never,
|
|
1286
|
+
layout: { sections: [{ title: "Pay", fields: ["amount"] }] } as never,
|
|
1287
|
+
});
|
|
1288
|
+
});
|
|
1289
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
1290
|
+
/Screen "invoice-pay" in feature "test", money field "amount"/,
|
|
1291
|
+
);
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1207
1294
|
// hasMoneyField used to only look at top-level fields, so an entity whose
|
|
1208
1295
|
// only money lives inside an embedded-list sub-schema (e.g. invoice lines
|
|
1209
1296
|
// with no top-level money field) slipped past this check — its cells and
|
|
@@ -20,8 +20,12 @@ const contactEntity = createEntity({
|
|
|
20
20
|
// hook) writes it. secretNote's ownership rule checks authorId, so
|
|
21
21
|
// create only succeeds if the hook ran BEFORE the field-ownership check
|
|
22
22
|
// (kumiko-framework#1672 — see also event-store-executor-write.ts).
|
|
23
|
-
authorId: createTextField(),
|
|
24
|
-
secretNote: createTextField({
|
|
23
|
+
authorId: createTextField({ personal: false, reason: "test_fixture" }),
|
|
24
|
+
secretNote: createTextField({
|
|
25
|
+
personal: false,
|
|
26
|
+
reason: "test_fixture",
|
|
27
|
+
access: { write: { User: from("user:id", "authorId") } },
|
|
28
|
+
}),
|
|
25
29
|
},
|
|
26
30
|
});
|
|
27
31
|
|
|
@@ -41,7 +41,12 @@ describe("createLongTextField — runtime shape", () => {
|
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
test("encrypted + sensitive flags type-allowed", () => {
|
|
44
|
-
const f = createLongTextField({
|
|
44
|
+
const f = createLongTextField({
|
|
45
|
+
personal: false,
|
|
46
|
+
reason: "test_fixture",
|
|
47
|
+
encrypted: true,
|
|
48
|
+
sensitive: true,
|
|
49
|
+
});
|
|
45
50
|
expect(f.encrypted).toBe(true);
|
|
46
51
|
expect(f.sensitive).toBe(true);
|
|
47
52
|
});
|