@cosmicdrift/kumiko-framework 0.209.1 → 0.211.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/entity-permalink-open.integration.test.ts +6 -1
- package/src/changes.json +56 -0
- package/src/crypto/__tests__/blind-index.test.ts +1 -1
- package/src/crypto/__tests__/pii-field-encryption.test.ts +16 -10
- package/src/crypto/__tests__/subject-resolver.test.ts +9 -3
- package/src/db/__tests__/blind-index.integration.test.ts +1 -1
- package/src/db/__tests__/cursor.test.ts +26 -1
- package/src/db/__tests__/eagerload.integration.test.ts +3 -3
- package/src/db/__tests__/entity-table-meta-source.test.ts +4 -1
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +144 -1
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -2
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +1 -1
- package/src/db/cursor.ts +32 -0
- package/src/db/event-store-executor-read.ts +80 -9
- package/src/db/index.ts +2 -2
- package/src/db/pg-error.ts +7 -0
- package/src/db/queries/backfill-pii.ts +188 -18
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +140 -140
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +17 -5
- package/src/engine/__tests__/factories-personal.test.ts +130 -0
- package/src/engine/__tests__/field-access.test.ts +1 -23
- package/src/engine/__tests__/store-table.test.ts +4 -1
- package/src/engine/boot-validator/pii-retention.ts +22 -54
- package/src/engine/build-config-feature-schema.ts +9 -1
- package/src/engine/factories.ts +108 -30
- package/src/engine/field-access.ts +2 -13
- package/src/engine/index.ts +7 -1
- package/src/engine/types/index.ts +7 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +179 -2
- package/src/files/file-ref-entity.ts +2 -2
- package/src/search/__tests__/reindex-entity.integration.test.ts +1 -1
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +2 -2
- package/src/testing/shared-entities.ts +6 -3
|
@@ -32,6 +32,13 @@ import {
|
|
|
32
32
|
createTimestampField,
|
|
33
33
|
createTzField,
|
|
34
34
|
} from "../factories";
|
|
35
|
+
import type { LongTextFieldDef, TextFieldDef } from "../types";
|
|
36
|
+
|
|
37
|
+
// The new personal/find union can no longer express some flag combinations
|
|
38
|
+
// on purpose (e.g. two subjects on one field) — these tests deliberately
|
|
39
|
+
// construct the raw, rejected shape to prove the boot-validator still throws.
|
|
40
|
+
const rawField = <T>(f: T) => f as unknown as TextFieldDef;
|
|
41
|
+
const rawLongTextField = <T>(f: T) => f as unknown as LongTextFieldDef;
|
|
35
42
|
|
|
36
43
|
// Stubt einen leeren `<entity>:list`-Query-Handler damit der reference-
|
|
37
44
|
// Field-Boot-Validator den Audit-Fix-#2-Check durchläßt. Wird gebraucht
|
|
@@ -64,7 +71,10 @@ describe("validateBoot — PII annotations", () => {
|
|
|
64
71
|
"user",
|
|
65
72
|
createEntity({
|
|
66
73
|
fields: {
|
|
67
|
-
email: createTextField({
|
|
74
|
+
email: createTextField({
|
|
75
|
+
personal: "self",
|
|
76
|
+
find: "none",
|
|
77
|
+
}),
|
|
68
78
|
},
|
|
69
79
|
}),
|
|
70
80
|
);
|
|
@@ -78,7 +88,10 @@ describe("validateBoot — PII annotations", () => {
|
|
|
78
88
|
"branding",
|
|
79
89
|
createEntity({
|
|
80
90
|
fields: {
|
|
81
|
-
brandColor: createTextField({
|
|
91
|
+
brandColor: createTextField({
|
|
92
|
+
personal: "tenant",
|
|
93
|
+
find: "none",
|
|
94
|
+
}),
|
|
82
95
|
},
|
|
83
96
|
}),
|
|
84
97
|
);
|
|
@@ -88,13 +101,26 @@ describe("validateBoot — PII annotations", () => {
|
|
|
88
101
|
|
|
89
102
|
test("userOwned with valid ownerField on reference passes", () => {
|
|
90
103
|
const feature = defineFeature("test", (r) => {
|
|
91
|
-
r.entity(
|
|
104
|
+
r.entity(
|
|
105
|
+
"user",
|
|
106
|
+
createEntity({
|
|
107
|
+
fields: {
|
|
108
|
+
email: createTextField({
|
|
109
|
+
personal: "self",
|
|
110
|
+
find: "none",
|
|
111
|
+
}),
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
92
115
|
stubListHandler(r, "user");
|
|
93
116
|
r.entity(
|
|
94
117
|
"comment",
|
|
95
118
|
createEntity({
|
|
96
119
|
fields: {
|
|
97
|
-
body: createLongTextField({
|
|
120
|
+
body: createLongTextField({
|
|
121
|
+
personal: { of: "authorId" },
|
|
122
|
+
find: "none",
|
|
123
|
+
}),
|
|
98
124
|
authorId: { type: "reference", entity: "user" },
|
|
99
125
|
},
|
|
100
126
|
}),
|
|
@@ -109,7 +135,11 @@ describe("validateBoot — PII annotations", () => {
|
|
|
109
135
|
"thing",
|
|
110
136
|
createEntity({
|
|
111
137
|
fields: {
|
|
112
|
-
confused:
|
|
138
|
+
confused: rawField({
|
|
139
|
+
...createTextField(),
|
|
140
|
+
pii: true,
|
|
141
|
+
tenantOwned: true,
|
|
142
|
+
}),
|
|
113
143
|
},
|
|
114
144
|
}),
|
|
115
145
|
);
|
|
@@ -137,7 +167,10 @@ describe("validateBoot — PII annotations", () => {
|
|
|
137
167
|
"vault",
|
|
138
168
|
createEntity({
|
|
139
169
|
fields: {
|
|
140
|
-
apiToken: createTextField({
|
|
170
|
+
apiToken: createTextField({
|
|
171
|
+
personal: "self",
|
|
172
|
+
find: "secret",
|
|
173
|
+
}),
|
|
141
174
|
},
|
|
142
175
|
}),
|
|
143
176
|
);
|
|
@@ -172,13 +205,16 @@ describe("validateBoot — PII annotations", () => {
|
|
|
172
205
|
"comment",
|
|
173
206
|
createEntity({
|
|
174
207
|
fields: {
|
|
175
|
-
body: createLongTextField({
|
|
208
|
+
body: createLongTextField({
|
|
209
|
+
personal: { of: "ghostField" },
|
|
210
|
+
find: "none",
|
|
211
|
+
}),
|
|
176
212
|
},
|
|
177
213
|
}),
|
|
178
214
|
);
|
|
179
215
|
});
|
|
180
216
|
expect(() => validateBoot([feature])).toThrow(
|
|
181
|
-
/
|
|
217
|
+
/personal\.of "ghostField" but no such field exists/,
|
|
182
218
|
);
|
|
183
219
|
});
|
|
184
220
|
|
|
@@ -188,7 +224,10 @@ describe("validateBoot — PII annotations", () => {
|
|
|
188
224
|
"comment",
|
|
189
225
|
createEntity({
|
|
190
226
|
fields: {
|
|
191
|
-
body: createLongTextField({
|
|
227
|
+
body: createLongTextField({
|
|
228
|
+
personal: { of: "authorId" },
|
|
229
|
+
find: "none",
|
|
230
|
+
}),
|
|
192
231
|
authorId: createTextField(),
|
|
193
232
|
},
|
|
194
233
|
}),
|
|
@@ -203,7 +242,10 @@ describe("validateBoot — PII annotations", () => {
|
|
|
203
242
|
"comment",
|
|
204
243
|
createEntity({
|
|
205
244
|
fields: {
|
|
206
|
-
body: createLongTextField({
|
|
245
|
+
body: createLongTextField({
|
|
246
|
+
personal: { of: "isPublic" },
|
|
247
|
+
find: "none",
|
|
248
|
+
}),
|
|
207
249
|
isPublic: { type: "boolean" },
|
|
208
250
|
},
|
|
209
251
|
}),
|
|
@@ -222,7 +264,10 @@ describe("validateBoot — PII annotations", () => {
|
|
|
222
264
|
"personalNote",
|
|
223
265
|
createEntity({
|
|
224
266
|
fields: {
|
|
225
|
-
body: createLongTextField({
|
|
267
|
+
body: createLongTextField({
|
|
268
|
+
personal: { of: "employeeId" },
|
|
269
|
+
find: "none",
|
|
270
|
+
}),
|
|
226
271
|
employeeId: { type: "reference", entity: "employee" },
|
|
227
272
|
},
|
|
228
273
|
}),
|
|
@@ -295,7 +340,9 @@ describe("validateBoot — PII annotations", () => {
|
|
|
295
340
|
"thing",
|
|
296
341
|
createEntity({
|
|
297
342
|
fields: {
|
|
298
|
-
authorId: createTextField({
|
|
343
|
+
authorId: createTextField({
|
|
344
|
+
personal: "ref",
|
|
345
|
+
}),
|
|
299
346
|
},
|
|
300
347
|
}),
|
|
301
348
|
);
|
|
@@ -313,7 +360,10 @@ describe("validateBoot — PII annotations", () => {
|
|
|
313
360
|
"company",
|
|
314
361
|
createEntity({
|
|
315
362
|
fields: {
|
|
316
|
-
name: createTextField({
|
|
363
|
+
name: createTextField({
|
|
364
|
+
personal: false,
|
|
365
|
+
reason: "is_business_data",
|
|
366
|
+
}),
|
|
317
367
|
},
|
|
318
368
|
}),
|
|
319
369
|
);
|
|
@@ -331,7 +381,10 @@ describe("validateBoot — PII annotations", () => {
|
|
|
331
381
|
"user",
|
|
332
382
|
createEntity({
|
|
333
383
|
fields: {
|
|
334
|
-
email: createTextField({
|
|
384
|
+
email: createTextField({
|
|
385
|
+
personal: "self",
|
|
386
|
+
find: "none",
|
|
387
|
+
}),
|
|
335
388
|
},
|
|
336
389
|
}),
|
|
337
390
|
);
|
|
@@ -351,7 +404,9 @@ describe("validateBoot — PII annotations", () => {
|
|
|
351
404
|
"payslip",
|
|
352
405
|
createEntity({
|
|
353
406
|
fields: {
|
|
354
|
-
grossAmount: createNumberField({
|
|
407
|
+
grossAmount: createNumberField({
|
|
408
|
+
personal: "self",
|
|
409
|
+
}),
|
|
355
410
|
},
|
|
356
411
|
}),
|
|
357
412
|
);
|
|
@@ -367,7 +422,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
367
422
|
fields: {
|
|
368
423
|
gender: createSelectField({
|
|
369
424
|
options: ["male", "female", "diverse", "prefer-not-to-say"] as const,
|
|
370
|
-
|
|
425
|
+
personal: "self",
|
|
371
426
|
}),
|
|
372
427
|
},
|
|
373
428
|
}),
|
|
@@ -384,7 +439,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
384
439
|
fields: {
|
|
385
440
|
dietaryRestrictions: createMultiSelectField({
|
|
386
441
|
options: ["vegan", "vegetarian", "halal", "kosher", "gluten-free"] as const,
|
|
387
|
-
|
|
442
|
+
personal: "self",
|
|
388
443
|
}),
|
|
389
444
|
},
|
|
390
445
|
}),
|
|
@@ -399,7 +454,9 @@ describe("validateBoot — PII annotations", () => {
|
|
|
399
454
|
"profile",
|
|
400
455
|
createEntity({
|
|
401
456
|
fields: {
|
|
402
|
-
dateOfBirth: createDateField({
|
|
457
|
+
dateOfBirth: createDateField({
|
|
458
|
+
personal: "self",
|
|
459
|
+
}),
|
|
403
460
|
},
|
|
404
461
|
}),
|
|
405
462
|
);
|
|
@@ -413,7 +470,9 @@ describe("validateBoot — PII annotations", () => {
|
|
|
413
470
|
"session",
|
|
414
471
|
createEntity({
|
|
415
472
|
fields: {
|
|
416
|
-
lastLoginAt: createTimestampField({
|
|
473
|
+
lastLoginAt: createTimestampField({
|
|
474
|
+
personal: "self",
|
|
475
|
+
}),
|
|
417
476
|
},
|
|
418
477
|
}),
|
|
419
478
|
);
|
|
@@ -427,7 +486,9 @@ describe("validateBoot — PII annotations", () => {
|
|
|
427
486
|
"profile",
|
|
428
487
|
createEntity({
|
|
429
488
|
fields: {
|
|
430
|
-
homeTz: createTzField({
|
|
489
|
+
homeTz: createTzField({
|
|
490
|
+
personal: "self",
|
|
491
|
+
}),
|
|
431
492
|
},
|
|
432
493
|
}),
|
|
433
494
|
);
|
|
@@ -441,7 +502,9 @@ describe("validateBoot — PII annotations", () => {
|
|
|
441
502
|
"shift",
|
|
442
503
|
createEntity({
|
|
443
504
|
fields: {
|
|
444
|
-
startsAt: createLocatedTimestampField({
|
|
505
|
+
startsAt: createLocatedTimestampField({
|
|
506
|
+
personal: "self",
|
|
507
|
+
}),
|
|
445
508
|
},
|
|
446
509
|
}),
|
|
447
510
|
);
|
|
@@ -461,7 +524,9 @@ describe("validateBoot — PII annotations", () => {
|
|
|
461
524
|
city: { type: "text" },
|
|
462
525
|
postalCode: { type: "text" },
|
|
463
526
|
},
|
|
464
|
-
{
|
|
527
|
+
{
|
|
528
|
+
personal: "self",
|
|
529
|
+
},
|
|
465
530
|
),
|
|
466
531
|
},
|
|
467
532
|
}),
|
|
@@ -593,8 +658,14 @@ describe("validateBoot — retention", () => {
|
|
|
593
658
|
"invoice",
|
|
594
659
|
createEntity({
|
|
595
660
|
fields: {
|
|
596
|
-
invoiceNumber: createTextField({
|
|
597
|
-
|
|
661
|
+
invoiceNumber: createTextField({
|
|
662
|
+
personal: false,
|
|
663
|
+
reason: "is_business_data",
|
|
664
|
+
}),
|
|
665
|
+
customerName: createTextField({
|
|
666
|
+
personal: "self",
|
|
667
|
+
find: "none",
|
|
668
|
+
}),
|
|
598
669
|
},
|
|
599
670
|
retention: { keepFor: "10y", strategy: "blockDelete" },
|
|
600
671
|
}),
|
|
@@ -613,7 +684,10 @@ describe("validateBoot — retention", () => {
|
|
|
613
684
|
"lease",
|
|
614
685
|
createEntity({
|
|
615
686
|
fields: {
|
|
616
|
-
reference: createTextField({
|
|
687
|
+
reference: createTextField({
|
|
688
|
+
personal: false,
|
|
689
|
+
reason: "is_business_data",
|
|
690
|
+
}),
|
|
617
691
|
},
|
|
618
692
|
retention: { keepFor: "10y", strategy: "blockDelete" },
|
|
619
693
|
}),
|
|
@@ -632,7 +706,9 @@ describe("validateBoot — retention", () => {
|
|
|
632
706
|
"lease",
|
|
633
707
|
createEntity({
|
|
634
708
|
fields: {
|
|
635
|
-
authorId: createTextField({
|
|
709
|
+
authorId: createTextField({
|
|
710
|
+
personal: "ref",
|
|
711
|
+
}),
|
|
636
712
|
},
|
|
637
713
|
retention: { keepFor: "10y", strategy: "blockDelete" },
|
|
638
714
|
}),
|
|
@@ -651,7 +727,10 @@ describe("validateBoot — retention", () => {
|
|
|
651
727
|
"thing",
|
|
652
728
|
createEntity({
|
|
653
729
|
fields: {
|
|
654
|
-
note: createTextField({
|
|
730
|
+
note: createTextField({
|
|
731
|
+
personal: false,
|
|
732
|
+
reason: "is_business_data",
|
|
733
|
+
}),
|
|
655
734
|
},
|
|
656
735
|
retention: { keepFor: "30days", strategy: "hardDelete" },
|
|
657
736
|
}),
|
|
@@ -672,7 +751,10 @@ describe("validateBoot — retention", () => {
|
|
|
672
751
|
"thing",
|
|
673
752
|
createEntity({
|
|
674
753
|
fields: {
|
|
675
|
-
note: createTextField({
|
|
754
|
+
note: createTextField({
|
|
755
|
+
personal: false,
|
|
756
|
+
reason: "is_business_data",
|
|
757
|
+
}),
|
|
676
758
|
},
|
|
677
759
|
retention: { keepFor, strategy: "hardDelete" },
|
|
678
760
|
}),
|
|
@@ -692,9 +774,13 @@ describe("validateBoot — retention", () => {
|
|
|
692
774
|
"invoice",
|
|
693
775
|
createEntity({
|
|
694
776
|
fields: {
|
|
695
|
-
invoiceNumber: createTextField({
|
|
777
|
+
invoiceNumber: createTextField({
|
|
778
|
+
personal: false,
|
|
779
|
+
reason: "is_business_data",
|
|
780
|
+
}),
|
|
696
781
|
customerName: createTextField({
|
|
697
|
-
|
|
782
|
+
personal: "self",
|
|
783
|
+
find: "none",
|
|
698
784
|
anonymize: () => "[ANONYMIZED]",
|
|
699
785
|
}),
|
|
700
786
|
},
|
|
@@ -717,7 +803,10 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
717
803
|
"user",
|
|
718
804
|
createEntity({
|
|
719
805
|
fields: {
|
|
720
|
-
email: createTextField({
|
|
806
|
+
email: createTextField({
|
|
807
|
+
personal: "self",
|
|
808
|
+
find: "exact",
|
|
809
|
+
}),
|
|
721
810
|
},
|
|
722
811
|
}),
|
|
723
812
|
);
|
|
@@ -731,12 +820,15 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
731
820
|
"doc",
|
|
732
821
|
createEntity({
|
|
733
822
|
fields: {
|
|
734
|
-
slug:
|
|
823
|
+
slug: rawField({
|
|
824
|
+
...createTextField(),
|
|
825
|
+
lookupable: true,
|
|
826
|
+
}),
|
|
735
827
|
},
|
|
736
828
|
}),
|
|
737
829
|
);
|
|
738
830
|
});
|
|
739
|
-
expect(() => validateBoot([feature])).toThrow(/
|
|
831
|
+
expect(() => validateBoot([feature])).toThrow(/find:.*without a subject annotation/);
|
|
740
832
|
});
|
|
741
833
|
|
|
742
834
|
test("lookupable on non-text field throws", () => {
|
|
@@ -745,8 +837,10 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
745
837
|
"person",
|
|
746
838
|
createEntity({
|
|
747
839
|
fields: {
|
|
748
|
-
|
|
749
|
-
|
|
840
|
+
bio: rawLongTextField({
|
|
841
|
+
...createLongTextField({ personal: { of: "ownerId" }, find: "none" }),
|
|
842
|
+
lookupable: true,
|
|
843
|
+
}),
|
|
750
844
|
ownerId: createTextField({ required: true }),
|
|
751
845
|
},
|
|
752
846
|
}),
|
|
@@ -761,7 +855,10 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
761
855
|
"user",
|
|
762
856
|
createEntity({
|
|
763
857
|
fields: {
|
|
764
|
-
displayName: createTextField({
|
|
858
|
+
displayName: createTextField({
|
|
859
|
+
personal: "self",
|
|
860
|
+
find: "fuzzy",
|
|
861
|
+
}),
|
|
765
862
|
},
|
|
766
863
|
}),
|
|
767
864
|
);
|
|
@@ -775,7 +872,11 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
775
872
|
"user",
|
|
776
873
|
createEntity({
|
|
777
874
|
fields: {
|
|
778
|
-
email: createTextField({
|
|
875
|
+
email: createTextField({
|
|
876
|
+
personal: "self",
|
|
877
|
+
find: "none",
|
|
878
|
+
sortable: true,
|
|
879
|
+
}),
|
|
779
880
|
},
|
|
780
881
|
}),
|
|
781
882
|
);
|
|
@@ -789,7 +890,8 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
789
890
|
"user",
|
|
790
891
|
createEntity({
|
|
791
892
|
fields: {
|
|
792
|
-
passwordHash:
|
|
893
|
+
passwordHash: rawField({
|
|
894
|
+
...createTextField(),
|
|
793
895
|
pii: true,
|
|
794
896
|
sensitive: true,
|
|
795
897
|
searchable: true,
|
|
@@ -801,105 +903,3 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
801
903
|
expect(() => validateBoot([feature])).toThrow(/sensitive.*searchable/);
|
|
802
904
|
});
|
|
803
905
|
});
|
|
804
|
-
|
|
805
|
-
describe("validateBoot — piiEncrypted (kumiko-platform#231/#456)", () => {
|
|
806
|
-
test("piiEncrypted on a plain text field passes", () => {
|
|
807
|
-
const feature = defineFeature("test", (r) => {
|
|
808
|
-
r.entity(
|
|
809
|
-
"tenant",
|
|
810
|
-
createEntity({
|
|
811
|
-
fields: {
|
|
812
|
-
iban: createTextField({
|
|
813
|
-
piiEncrypted: true,
|
|
814
|
-
tenantOwned: true,
|
|
815
|
-
access: { read: ["TenantAdmin"] },
|
|
816
|
-
}),
|
|
817
|
-
},
|
|
818
|
-
}),
|
|
819
|
-
);
|
|
820
|
-
});
|
|
821
|
-
expect(() => validateBoot([feature])).not.toThrow();
|
|
822
|
-
});
|
|
823
|
-
|
|
824
|
-
test("piiEncrypted on a non-text field throws", () => {
|
|
825
|
-
const feature = defineFeature("test", (r) => {
|
|
826
|
-
r.entity(
|
|
827
|
-
"tenant",
|
|
828
|
-
createEntity({
|
|
829
|
-
fields: {
|
|
830
|
-
amount: { ...createNumberField(), piiEncrypted: true } as unknown as ReturnType<
|
|
831
|
-
typeof createNumberField
|
|
832
|
-
>,
|
|
833
|
-
},
|
|
834
|
-
}),
|
|
835
|
-
);
|
|
836
|
-
});
|
|
837
|
-
expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*only applies to text fields/);
|
|
838
|
-
});
|
|
839
|
-
|
|
840
|
-
test("piiEncrypted without a subject annotation throws (kumiko-platform#457)", () => {
|
|
841
|
-
const feature = defineFeature("test", (r) => {
|
|
842
|
-
r.entity(
|
|
843
|
-
"tenant",
|
|
844
|
-
createEntity({
|
|
845
|
-
fields: {
|
|
846
|
-
iban: createTextField({ piiEncrypted: true }),
|
|
847
|
-
},
|
|
848
|
-
}),
|
|
849
|
-
);
|
|
850
|
-
});
|
|
851
|
-
expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*without a subject annotation/);
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
test("piiEncrypted combined with searchable passes (#1610)", () => {
|
|
855
|
-
const feature = defineFeature("test", (r) => {
|
|
856
|
-
r.entity(
|
|
857
|
-
"tenant",
|
|
858
|
-
createEntity({
|
|
859
|
-
fields: {
|
|
860
|
-
iban: createTextField({
|
|
861
|
-
piiEncrypted: true,
|
|
862
|
-
tenantOwned: true,
|
|
863
|
-
access: { read: ["TenantAdmin"] },
|
|
864
|
-
searchable: true,
|
|
865
|
-
}),
|
|
866
|
-
},
|
|
867
|
-
}),
|
|
868
|
-
);
|
|
869
|
-
});
|
|
870
|
-
expect(() => validateBoot([feature])).not.toThrow();
|
|
871
|
-
});
|
|
872
|
-
|
|
873
|
-
test("piiEncrypted combined with sortable throws", () => {
|
|
874
|
-
const feature = defineFeature("test", (r) => {
|
|
875
|
-
r.entity(
|
|
876
|
-
"tenant",
|
|
877
|
-
createEntity({
|
|
878
|
-
fields: {
|
|
879
|
-
iban: createTextField({
|
|
880
|
-
piiEncrypted: true,
|
|
881
|
-
tenantOwned: true,
|
|
882
|
-
access: { read: ["TenantAdmin"] },
|
|
883
|
-
sortable: true,
|
|
884
|
-
}),
|
|
885
|
-
},
|
|
886
|
-
}),
|
|
887
|
-
);
|
|
888
|
-
});
|
|
889
|
-
expect(() => validateBoot([feature])).toThrow(/sortable/);
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
test("piiEncrypted without access.read throws (kumiko-platform#460)", () => {
|
|
893
|
-
const feature = defineFeature("test", (r) => {
|
|
894
|
-
r.entity(
|
|
895
|
-
"tenant",
|
|
896
|
-
createEntity({
|
|
897
|
-
fields: {
|
|
898
|
-
iban: createTextField({ piiEncrypted: true, tenantOwned: true }),
|
|
899
|
-
},
|
|
900
|
-
}),
|
|
901
|
-
);
|
|
902
|
-
});
|
|
903
|
-
expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*without.*access.*read/);
|
|
904
|
-
});
|
|
905
|
-
});
|
|
@@ -63,9 +63,17 @@ describe("S0 Integration — full surface stack", () => {
|
|
|
63
63
|
"user",
|
|
64
64
|
createEntity({
|
|
65
65
|
fields: {
|
|
66
|
-
email: createTextField({
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
email: createTextField({
|
|
67
|
+
personal: "self",
|
|
68
|
+
find: "none",
|
|
69
|
+
}),
|
|
70
|
+
displayName: createTextField({
|
|
71
|
+
personal: "self",
|
|
72
|
+
find: "none",
|
|
73
|
+
}),
|
|
74
|
+
lastLoginAt: createTimestampField({
|
|
75
|
+
personal: "self",
|
|
76
|
+
}),
|
|
69
77
|
},
|
|
70
78
|
retention: {
|
|
71
79
|
keepFor: "10y",
|
|
@@ -80,7 +88,8 @@ describe("S0 Integration — full surface stack", () => {
|
|
|
80
88
|
createEntity({
|
|
81
89
|
fields: {
|
|
82
90
|
body: createLongTextField({
|
|
83
|
-
|
|
91
|
+
personal: { of: "authorId" },
|
|
92
|
+
find: "none",
|
|
84
93
|
anonymize: () => "[ANONYMIZED]",
|
|
85
94
|
}),
|
|
86
95
|
authorId: { type: "reference", entity: "user" },
|
|
@@ -130,7 +139,10 @@ describe("S0 Integration — full surface stack", () => {
|
|
|
130
139
|
"user",
|
|
131
140
|
createEntity({
|
|
132
141
|
fields: {
|
|
133
|
-
email: createTextField({
|
|
142
|
+
email: createTextField({
|
|
143
|
+
personal: "self",
|
|
144
|
+
find: "none",
|
|
145
|
+
}),
|
|
134
146
|
},
|
|
135
147
|
retention: {
|
|
136
148
|
keepFor: "30d",
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Unit tests for the `personal`/`find` PII annotation resolution
|
|
2
|
+
// (kumiko-framework#2250) — the `expandPersonalAnnotations` helper in
|
|
3
|
+
// ../factories, exercised through the public factory functions.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { createLongTextField, createTextField, createTimestampField } from "../factories";
|
|
7
|
+
|
|
8
|
+
describe("createTextField — personal/find resolution", () => {
|
|
9
|
+
test("no annotation at all: no PII flags, no personal/find/reason leak into the field", () => {
|
|
10
|
+
const f = createTextField({ required: true });
|
|
11
|
+
expect(f).toEqual({
|
|
12
|
+
type: "text",
|
|
13
|
+
maxLength: 200,
|
|
14
|
+
required: true,
|
|
15
|
+
searchable: false,
|
|
16
|
+
sortable: false,
|
|
17
|
+
});
|
|
18
|
+
expect(f).not.toHaveProperty("personal");
|
|
19
|
+
expect(f).not.toHaveProperty("find");
|
|
20
|
+
expect(f).not.toHaveProperty("reason");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('personal: "self", find: "exact" → pii + lookupable', () => {
|
|
24
|
+
const f = createTextField({ personal: "self", find: "exact" });
|
|
25
|
+
expect(f.pii).toBe(true);
|
|
26
|
+
expect(f.lookupable).toBe(true);
|
|
27
|
+
expect(f.searchable).toBe(false);
|
|
28
|
+
expect(f).not.toHaveProperty("personal");
|
|
29
|
+
expect(f).not.toHaveProperty("find");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('personal: "self", find: "fuzzy" → pii + lookupable + searchable', () => {
|
|
33
|
+
const f = createTextField({ personal: "self", find: "fuzzy" });
|
|
34
|
+
expect(f.pii).toBe(true);
|
|
35
|
+
expect(f.lookupable).toBe(true);
|
|
36
|
+
expect(f.searchable).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('personal: "self", find: "none" → pii only, no lookup/search flags', () => {
|
|
40
|
+
const f = createTextField({ personal: "self", find: "none" });
|
|
41
|
+
expect(f.pii).toBe(true);
|
|
42
|
+
expect(f.lookupable).toBeUndefined();
|
|
43
|
+
expect(f.searchable).toBe(false);
|
|
44
|
+
expect(f.sensitive).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('personal: "self", find: "secret" → pii + sensitive, no lookup/search', () => {
|
|
48
|
+
const f = createTextField({ personal: "self", find: "secret" });
|
|
49
|
+
expect(f.pii).toBe(true);
|
|
50
|
+
expect(f.sensitive).toBe(true);
|
|
51
|
+
expect(f.lookupable).toBeUndefined();
|
|
52
|
+
expect(f.searchable).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('personal: "tenant", find: "exact" → tenantOwned + lookupable', () => {
|
|
56
|
+
const f = createTextField({ personal: "tenant", find: "exact" });
|
|
57
|
+
expect(f.tenantOwned).toBe(true);
|
|
58
|
+
expect(f.lookupable).toBe(true);
|
|
59
|
+
expect(f.pii).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('personal: { of: "ownerId" }, find: "exact" → userOwned + lookupable', () => {
|
|
63
|
+
const f = createTextField({ personal: { of: "ownerId" }, find: "exact" });
|
|
64
|
+
expect(f.userOwned).toEqual({ ownerField: "ownerId" });
|
|
65
|
+
expect(f.lookupable).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('personal: "ref" → subjectRef only, no find allowed on this variant', () => {
|
|
69
|
+
const f = createTextField({ personal: "ref" });
|
|
70
|
+
expect(f.subjectRef).toBe(true);
|
|
71
|
+
expect(f.pii).toBeUndefined();
|
|
72
|
+
expect(f.lookupable).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("personal: false, reason given → allowPlaintext carries the reason", () => {
|
|
76
|
+
const f = createTextField({ personal: false, reason: "public display name, not identifying" });
|
|
77
|
+
expect(f.allowPlaintext).toBe("public display name, not identifying");
|
|
78
|
+
expect(f.pii).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("anonymize survives resolution alongside a subject annotation", () => {
|
|
82
|
+
const anonymize = () => "[ANONYMIZED]";
|
|
83
|
+
const f = createTextField({ personal: "self", find: "none", anonymize });
|
|
84
|
+
expect(f.pii).toBe(true);
|
|
85
|
+
expect(f.anonymize).toBe(anonymize);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("other overrides (e.g. maxLength) pass through unaffected", () => {
|
|
89
|
+
const f = createTextField({ personal: "self", find: "none", maxLength: 500 });
|
|
90
|
+
expect(f.maxLength).toBe(500);
|
|
91
|
+
expect(f.pii).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("createLongTextField — restricted find (PersonalAnnotationsLongText)", () => {
|
|
96
|
+
test('personal: "self", find: "none" → pii only, no lookup/search flags', () => {
|
|
97
|
+
const f = createLongTextField({ personal: "self", find: "none" });
|
|
98
|
+
expect(f.pii).toBe(true);
|
|
99
|
+
expect(f.lookupable).toBeUndefined();
|
|
100
|
+
expect(f).not.toHaveProperty("searchable");
|
|
101
|
+
expect(f.sensitive).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('personal: "self", find: "secret" → pii + sensitive, no lookup/search', () => {
|
|
105
|
+
const f = createLongTextField({ personal: "self", find: "secret" });
|
|
106
|
+
expect(f.pii).toBe(true);
|
|
107
|
+
expect(f.sensitive).toBe(true);
|
|
108
|
+
expect(f.lookupable).toBeUndefined();
|
|
109
|
+
expect(f).not.toHaveProperty("searchable");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("createTimestampField — personal without find (PersonalAnnotationsNoFind)", () => {
|
|
114
|
+
test("no annotation at all: plain field, no PII flags", () => {
|
|
115
|
+
const f = createTimestampField({ required: true });
|
|
116
|
+
expect(f).toEqual({ type: "timestamp", required: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('personal: "self" alone (no find on non-text factories) → pii only', () => {
|
|
120
|
+
const f = createTimestampField({ personal: "self" });
|
|
121
|
+
expect(f.pii).toBe(true);
|
|
122
|
+
expect(f).not.toHaveProperty("personal");
|
|
123
|
+
expect(f).not.toHaveProperty("find");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("personal: false, reason given → allowPlaintext", () => {
|
|
127
|
+
const f = createTimestampField({ personal: false, reason: "server-generated, not user data" });
|
|
128
|
+
expect(f.allowPlaintext).toBe("server-generated, not user data");
|
|
129
|
+
});
|
|
130
|
+
});
|