@cosmicdrift/kumiko-bundled-features 0.173.0 → 0.173.1
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 +7 -7
- package/src/auth-email-password/__tests__/seed-admin.integration.test.ts +27 -0
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +143 -2
- package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +5 -0
- package/src/user/__tests__/user.integration.test.ts +17 -0
- package/src/user-data-rights/__tests__/default-mailers.test.ts +31 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.173.
|
|
3
|
+
"version": "0.173.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -119,12 +119,12 @@
|
|
|
119
119
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
120
120
|
},
|
|
121
121
|
"dependencies": {
|
|
122
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.173.
|
|
123
|
-
"@cosmicdrift/kumiko-framework": "0.173.
|
|
124
|
-
"@cosmicdrift/kumiko-headless": "0.173.
|
|
125
|
-
"@cosmicdrift/kumiko-renderer": "0.173.
|
|
126
|
-
"@cosmicdrift/kumiko-renderer-web": "0.173.
|
|
127
|
-
"@cosmicdrift/kumiko-types": "0.173.
|
|
122
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.173.1",
|
|
123
|
+
"@cosmicdrift/kumiko-framework": "0.173.1",
|
|
124
|
+
"@cosmicdrift/kumiko-headless": "0.173.1",
|
|
125
|
+
"@cosmicdrift/kumiko-renderer": "0.173.1",
|
|
126
|
+
"@cosmicdrift/kumiko-renderer-web": "0.173.1",
|
|
127
|
+
"@cosmicdrift/kumiko-types": "0.173.1",
|
|
128
128
|
"@mollie/api-client": "^4.5.0",
|
|
129
129
|
"imapflow": "^1.3.3",
|
|
130
130
|
"mailparser": "^3.9.8",
|
|
@@ -111,6 +111,33 @@ describe("seedAdmin", () => {
|
|
|
111
111
|
expect(betaMembership[0]?.["roles"]).toBe(JSON.stringify(["User"]));
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
test("emailVerified: true wird an seedUserWithPassword durchgereicht", async () => {
|
|
115
|
+
await seedAdmin(stack.db, {
|
|
116
|
+
email: "verified@example.com",
|
|
117
|
+
password: "secret-pw",
|
|
118
|
+
displayName: "Verified Admin",
|
|
119
|
+
emailVerified: true,
|
|
120
|
+
memberships: [
|
|
121
|
+
{ tenantId: TENANT_DEV, tenantKey: "dev", tenantName: "Dev", roles: ["Admin"] },
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
const [user] = await selectMany(stack.db, userTable, { email: "verified@example.com" });
|
|
125
|
+
expect(user?.["emailVerified"]).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("emailVerified: ohne Flag bleibt der User unverifiziert (Default)", async () => {
|
|
129
|
+
await seedAdmin(stack.db, {
|
|
130
|
+
email: "unverified@example.com",
|
|
131
|
+
password: "secret-pw",
|
|
132
|
+
displayName: "Unverified Admin",
|
|
133
|
+
memberships: [
|
|
134
|
+
{ tenantId: TENANT_DEV, tenantKey: "dev", tenantName: "Dev", roles: ["Admin"] },
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
const [user] = await selectMany(stack.db, userTable, { email: "unverified@example.com" });
|
|
138
|
+
expect(user?.["emailVerified"]).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
114
141
|
test("idempotent: zweiter Aufruf no-op (kein Crash, Stand bleibt)", async () => {
|
|
115
142
|
// Erstaufruf
|
|
116
143
|
const { id: userId1 } = await seedAdmin(stack.db, {
|
|
@@ -10,9 +10,25 @@
|
|
|
10
10
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
11
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
12
|
import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
13
|
-
import
|
|
13
|
+
import {
|
|
14
|
+
buildEntityTable,
|
|
15
|
+
createEventStoreExecutor,
|
|
16
|
+
createTenantDb,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
18
|
+
import {
|
|
19
|
+
createEntity,
|
|
20
|
+
createTextField,
|
|
21
|
+
defineFeature,
|
|
22
|
+
type TenantId,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
14
24
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
setupTestStack,
|
|
27
|
+
type TestStack,
|
|
28
|
+
TestUsers,
|
|
29
|
+
testTenantId,
|
|
30
|
+
unsafeCreateEntityTable,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
32
|
import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
17
33
|
import { SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
|
|
18
34
|
import { createCryptoShreddingFeature } from "../feature";
|
|
@@ -142,3 +158,128 @@ describe("crypto-shredding :: forget-subject", () => {
|
|
|
142
158
|
expect(err.httpStatus).toBe(400);
|
|
143
159
|
});
|
|
144
160
|
});
|
|
161
|
+
|
|
162
|
+
// fw#1611: forget-subject's ctx.searchAdapter wiring (forget-subject.write.ts
|
|
163
|
+
// → purgeSearchDocumentsForSubject) had zero end-to-end coverage — a broken
|
|
164
|
+
// extension registration would silently turn the whole GDPR search purge
|
|
165
|
+
// into a no-op. Own stack (own entity feature) so it doesn't perturb the
|
|
166
|
+
// scenarios above.
|
|
167
|
+
describe("crypto-shredding :: forget-subject purges the derived search index (#1611)", () => {
|
|
168
|
+
const probeEntity = createEntity({
|
|
169
|
+
table: "read_forget_subject_search_probe",
|
|
170
|
+
fields: {
|
|
171
|
+
ownerId: createTextField({ required: true }),
|
|
172
|
+
userNote: createTextField({
|
|
173
|
+
required: true,
|
|
174
|
+
maxLength: 100,
|
|
175
|
+
userOwned: { ownerField: "ownerId" },
|
|
176
|
+
searchable: true,
|
|
177
|
+
}),
|
|
178
|
+
tenantNote: createTextField({
|
|
179
|
+
required: true,
|
|
180
|
+
maxLength: 100,
|
|
181
|
+
tenantOwned: true,
|
|
182
|
+
searchable: true,
|
|
183
|
+
}),
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
const probeTable = buildEntityTable("forgetSubjectSearchProbe", probeEntity);
|
|
187
|
+
const probeFeature = defineFeature("forget-subject-search-probe", (r) => {
|
|
188
|
+
r.entity("probe", probeEntity);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
let searchStack: TestStack;
|
|
192
|
+
let searchKms: InMemoryKmsAdapter;
|
|
193
|
+
const admin = TestUsers.admin;
|
|
194
|
+
|
|
195
|
+
beforeAll(async () => {
|
|
196
|
+
searchStack = await setupTestStack({
|
|
197
|
+
features: [createCryptoShreddingFeature(), probeFeature],
|
|
198
|
+
});
|
|
199
|
+
await unsafeCreateEntityTable(searchStack.db, probeEntity, "probe");
|
|
200
|
+
await createEventsTable(searchStack.db);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
afterAll(async () => {
|
|
204
|
+
await searchStack.cleanup();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
searchKms = new InMemoryKmsAdapter();
|
|
209
|
+
configurePiiSubjectKms(searchKms);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
afterEach(() => {
|
|
213
|
+
resetPiiSubjectKmsForTests();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
function probeExecutor() {
|
|
217
|
+
return createEventStoreExecutor(probeTable, probeEntity, {
|
|
218
|
+
entityName: "probe",
|
|
219
|
+
searchAdapter: searchStack.search,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function probeTenantDb() {
|
|
224
|
+
return createTenantDb(searchStack.db, admin.tenantId, "system");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
test("forget-subject over HTTP purges userOwned rows from the search index", async () => {
|
|
228
|
+
const ownerId = crypto.randomUUID();
|
|
229
|
+
const userNote = "UniqueUserOwnedNote1611";
|
|
230
|
+
const created = await probeExecutor().create(
|
|
231
|
+
{ ownerId, userNote, tenantNote: "irrelevant-tenant-note" },
|
|
232
|
+
admin,
|
|
233
|
+
probeTenantDb(),
|
|
234
|
+
);
|
|
235
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
236
|
+
const id = String(created.data.id);
|
|
237
|
+
|
|
238
|
+
await searchStack.eventDispatcher?.runOnce();
|
|
239
|
+
expect(
|
|
240
|
+
(await searchStack.search.search(admin.tenantId, userNote, { filterType: "probe" })).some(
|
|
241
|
+
(h) => String(h.entityId) === id,
|
|
242
|
+
),
|
|
243
|
+
).toBe(true);
|
|
244
|
+
|
|
245
|
+
await searchStack.http.writeOk(
|
|
246
|
+
FORGET,
|
|
247
|
+
{ subject: { kind: "user", userId: ownerId }, reason: REASON },
|
|
248
|
+
dpoUser,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const after = await searchStack.search.search(admin.tenantId, userNote, {
|
|
252
|
+
filterType: "probe",
|
|
253
|
+
});
|
|
254
|
+
expect(after.some((h) => String(h.entityId) === id)).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("forget-subject over HTTP purges tenantOwned rows from the search index", async () => {
|
|
258
|
+
const tenantNote = "UniqueTenantOwnedNote1611";
|
|
259
|
+
const created = await probeExecutor().create(
|
|
260
|
+
{ ownerId: crypto.randomUUID(), userNote: "irrelevant-user-note", tenantNote },
|
|
261
|
+
admin,
|
|
262
|
+
probeTenantDb(),
|
|
263
|
+
);
|
|
264
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
265
|
+
const id = String(created.data.id);
|
|
266
|
+
|
|
267
|
+
await searchStack.eventDispatcher?.runOnce();
|
|
268
|
+
expect(
|
|
269
|
+
(await searchStack.search.search(admin.tenantId, tenantNote, { filterType: "probe" })).some(
|
|
270
|
+
(h) => String(h.entityId) === id,
|
|
271
|
+
),
|
|
272
|
+
).toBe(true);
|
|
273
|
+
|
|
274
|
+
await searchStack.http.writeOk(
|
|
275
|
+
FORGET,
|
|
276
|
+
{ subject: { kind: "tenant", tenantId: admin.tenantId }, reason: REASON },
|
|
277
|
+
dpoUser,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const after = await searchStack.search.search(admin.tenantId, tenantNote, {
|
|
281
|
+
filterType: "probe",
|
|
282
|
+
});
|
|
283
|
+
expect(after.some((h) => String(h.entityId) === id)).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -225,6 +225,11 @@ describe("delivery attempt log under KMS", () => {
|
|
|
225
225
|
const otherSubjectBefore = beforeErase.rows.find(
|
|
226
226
|
(r) => r["recipientId"] === recipient.id && r["channel"] === "email",
|
|
227
227
|
)?.["recipientAddress"];
|
|
228
|
+
// Guard against a vacuous pass: if the row fell outside the `limit 100`
|
|
229
|
+
// window, both sides below would be `undefined` and the "unaffected"
|
|
230
|
+
// assertion would prove nothing.
|
|
231
|
+
expect(otherSubjectBefore).toBeDefined();
|
|
232
|
+
expect(otherSubjectBefore).not.toBe(PII_ERASED_SENTINEL);
|
|
228
233
|
|
|
229
234
|
await kms.eraseKey({ kind: "user", userId: subjectId });
|
|
230
235
|
|
|
@@ -69,6 +69,23 @@ describe("scenario 1: create + me", () => {
|
|
|
69
69
|
expect(me["locale"] == null).toBe(true);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
test("locale sent by the client is persisted and returned via me", async () => {
|
|
73
|
+
const created = await stack.http.writeOk<{ id: number }>(
|
|
74
|
+
UserHandlers.create,
|
|
75
|
+
{
|
|
76
|
+
email: "locale@example.com",
|
|
77
|
+
displayName: "Locale User",
|
|
78
|
+
passwordHash: "seeded-hash",
|
|
79
|
+
locale: "de",
|
|
80
|
+
},
|
|
81
|
+
systemAdmin,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const signedIn = createTestUser({ id: created.id, roles: ["User"] });
|
|
85
|
+
const me = await stack.http.queryOk<Record<string, unknown>>(UserQueries.me, {}, signedIn);
|
|
86
|
+
expect(me["locale"]).toBe("de");
|
|
87
|
+
});
|
|
88
|
+
|
|
72
89
|
test("normal user cannot create another user", async () => {
|
|
73
90
|
const normal = createTestUser({ id: 42, roles: ["User"] });
|
|
74
91
|
const error = await stack.http.writeErr(
|
|
@@ -91,6 +91,37 @@ describe("default-mailers dispatch", () => {
|
|
|
91
91
|
expect(cap.sent[1]?.subject).toBe("Acme — Dein Datenexport ist bereit");
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
test("null user.locale (der Normalfall seit fw#1637, kein Entity-Default mehr): mailDefaults.locale entscheidet, sonst en", async () => {
|
|
95
|
+
const cap = capturingTransport();
|
|
96
|
+
const resolve = async () => cap.transport;
|
|
97
|
+
|
|
98
|
+
// userLocale: null + mailDefaults.locale: "de" → German subject.
|
|
99
|
+
await makeDefaultExportReadyEmail(resolve, { locale: "de", appName: "Acme" })({
|
|
100
|
+
userId: "u1",
|
|
101
|
+
userEmail: "u1@example.com",
|
|
102
|
+
userLocale: null,
|
|
103
|
+
tenantId: TENANT_A,
|
|
104
|
+
jobId: "j1",
|
|
105
|
+
downloadUrl: "u",
|
|
106
|
+
expiresAt: "x",
|
|
107
|
+
bytesWritten: null,
|
|
108
|
+
});
|
|
109
|
+
expect(cap.sent[0]?.subject).toBe("Acme — Dein Datenexport ist bereit");
|
|
110
|
+
|
|
111
|
+
// userLocale: null + no mailDefaults.locale → template default "en".
|
|
112
|
+
await makeDefaultExportReadyEmail(resolve, { appName: "Acme" })({
|
|
113
|
+
userId: "u2",
|
|
114
|
+
userEmail: "u2@example.com",
|
|
115
|
+
userLocale: null,
|
|
116
|
+
tenantId: TENANT_A,
|
|
117
|
+
jobId: "j2",
|
|
118
|
+
downloadUrl: "u",
|
|
119
|
+
expiresAt: "x",
|
|
120
|
+
bytesWritten: null,
|
|
121
|
+
});
|
|
122
|
+
expect(cap.sent[1]?.subject).toBe("Acme — Your data export is ready");
|
|
123
|
+
});
|
|
124
|
+
|
|
94
125
|
test("deletion-executed: sendet ueber den ersten Membership-Tenant", async () => {
|
|
95
126
|
const cap = capturingTransport();
|
|
96
127
|
const resolved: string[] = [];
|