@cosmicdrift/kumiko-bundled-features 0.25.0 → 0.26.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 +1 -1
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +75 -11
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +86 -16
- package/src/auth-email-password/handlers/confirm-token-flow.ts +12 -8
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +196 -75
- package/src/custom-fields/db/queries/field-access.ts +1 -1
- package/src/custom-fields/db/queries/quota.ts +1 -1
- package/src/file-provider-inmemory/__tests__/feature.test.ts +55 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +27 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +54 -12
- package/src/foundation-shared/__tests__/config-helpers.test.ts +72 -0
- package/src/foundation-shared/config-helpers.ts +7 -3
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +55 -0
- package/src/tier-engine/feature.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
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>",
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
asRawClient,
|
|
5
|
+
insertOne,
|
|
6
|
+
selectMany,
|
|
7
|
+
updateMany,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
9
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
5
10
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
11
|
import {
|
|
@@ -204,27 +209,86 @@ describe("POST /auth/verify-email", () => {
|
|
|
204
209
|
});
|
|
205
210
|
|
|
206
211
|
test("verify that fails before the write is retryable (burn released on failure)", async () => {
|
|
207
|
-
// Symmetric to the reset-password retry test: if the confirm-flow
|
|
208
|
-
//
|
|
209
|
-
// the
|
|
210
|
-
//
|
|
212
|
+
// Symmetric to the reset-password retry test: if the confirm-flow fails
|
|
213
|
+
// AFTER burning, the finally-block in runConfirmTokenFlow releases the
|
|
214
|
+
// burn so the user can click the same link again once ops restores state.
|
|
215
|
+
//
|
|
216
|
+
// Trigger: delete the user READ-MODEL row (kumiko_events untouched) →
|
|
217
|
+
// loadValidatedUser returns null after the burn → invalidToken + unburn.
|
|
218
|
+
// Re-insert the same row verbatim → retry with the SAME token succeeds.
|
|
219
|
+
//
|
|
220
|
+
// (Membership-deletion no longer fails: the stream lives in
|
|
221
|
+
// systemAdmin.tenantId and is recovered with zero memberships — see the
|
|
222
|
+
// zero-membership-sysadmin test below.)
|
|
211
223
|
const seed = await seedUser({ email: "retry@example.com", password: "pw-retry-1234" });
|
|
212
224
|
const { token } = signVerificationToken(seed.id, 60, verifySecret);
|
|
213
225
|
|
|
214
|
-
await
|
|
226
|
+
const userRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
|
|
227
|
+
if (!userRow) throw new Error("seeded user row missing");
|
|
228
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}" WHERE id = $1`, [
|
|
229
|
+
seed.id,
|
|
230
|
+
]);
|
|
231
|
+
|
|
215
232
|
const firstAttempt = await post("/api/auth/verify-email", { token });
|
|
216
233
|
expect(firstAttempt.status).toBe(422);
|
|
217
234
|
|
|
218
|
-
await
|
|
219
|
-
userId: seed.id,
|
|
220
|
-
tenantId: seed.tenantId,
|
|
221
|
-
roles: ["User"],
|
|
222
|
-
});
|
|
235
|
+
await insertOne(stack.db, userTable, userRow);
|
|
223
236
|
|
|
224
237
|
const secondAttempt = await post("/api/auth/verify-email", { token });
|
|
225
238
|
expect(secondAttempt.status).toBe(200);
|
|
226
239
|
});
|
|
227
240
|
|
|
241
|
+
test("zero-membership sysadmin can still verify (stream recovered without any membership)", async () => {
|
|
242
|
+
// 205#1: a systemScope user whose stream lives in systemAdmin.tenantId
|
|
243
|
+
// (…0001) but who holds NO membership must still resolve. The stream-
|
|
244
|
+
// tenant recovery runs BEFORE the empty-membership check, so verify
|
|
245
|
+
// targets …0001 and lands instead of collapsing to invalid_token.
|
|
246
|
+
const seed = await seedUser({ email: "lonely-admin@example.com", password: "pw-lonely-1234" });
|
|
247
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
248
|
+
|
|
249
|
+
const streamRows = (await asRawClient(stack.db).unsafe(
|
|
250
|
+
`SELECT "tenant_id" FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = 'user' ORDER BY "version" LIMIT 1`,
|
|
251
|
+
[seed.id],
|
|
252
|
+
)) as ReadonlyArray<{ tenant_id: string }>;
|
|
253
|
+
expect(streamRows[0]?.tenant_id).toBe(systemAdmin.tenantId);
|
|
254
|
+
|
|
255
|
+
const { token } = signVerificationToken(seed.id, 60, verifySecret);
|
|
256
|
+
const res = await post("/api/auth/verify-email", { token });
|
|
257
|
+
expect(res.status).toBe(200);
|
|
258
|
+
|
|
259
|
+
const row = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
|
|
260
|
+
expect(row?.["emailVerified"]).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("direct-inserted user with no stream + no membership → 422 (recovery stays bounded)", async () => {
|
|
264
|
+
// 205#1 "strikt sicher" boundary: a user inserted straight into the read
|
|
265
|
+
// model (no create-event → no event stream) with no membership has
|
|
266
|
+
// nothing to recover → invalidToken. Proves the fix only gains "empty
|
|
267
|
+
// memberships + recoverable stream", never blanket-opens zero-membership.
|
|
268
|
+
// Build a fully-populated user row with NO event stream: seed a normal
|
|
269
|
+
// user (gets all NOT-NULL columns), capture its row, then re-key it to a
|
|
270
|
+
// fresh id + email. getAggregateStreamTenant(orphanId) finds no events
|
|
271
|
+
// (the stream lives under the original id), and no membership is seeded
|
|
272
|
+
// → tenantOrder is empty.
|
|
273
|
+
const donor = await seedUser({ email: "donor@example.com", password: "donor-pw-1234" });
|
|
274
|
+
const donorRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === donor.id);
|
|
275
|
+
if (!donorRow) throw new Error("donor row missing");
|
|
276
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
277
|
+
|
|
278
|
+
const orphanId = "00000000-0000-4000-8000-0000000000ff";
|
|
279
|
+
await insertOne(stack.db, userTable, {
|
|
280
|
+
...donorRow,
|
|
281
|
+
id: orphanId,
|
|
282
|
+
email: "orphan@example.com",
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const { token } = signVerificationToken(orphanId, 60, verifySecret);
|
|
286
|
+
const res = await post("/api/auth/verify-email", { token });
|
|
287
|
+
expect(res.status).toBe(422);
|
|
288
|
+
const body = await res.json();
|
|
289
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidVerificationToken);
|
|
290
|
+
});
|
|
291
|
+
|
|
228
292
|
test("cross-purpose burn isolation: consuming a reset-token doesn't block a verify-token for the same user+expiry", async () => {
|
|
229
293
|
// The burn-store key includes purpose ("reset" vs "verify"). Tokens
|
|
230
294
|
// signed with the SAME userId + expiresAtMs but different purpose
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
4
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
5
5
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import {
|
|
@@ -251,31 +251,39 @@ describe("POST /auth/reset-password", () => {
|
|
|
251
251
|
|
|
252
252
|
test("reset that fails before the write is retryable (burn is released on failure)", async () => {
|
|
253
253
|
// The burn marker goes down BEFORE the state change so a racing replay
|
|
254
|
-
// can't slip through. But if the state change itself fails —
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
// without hitting a stuck "already-used".
|
|
254
|
+
// can't slip through. But if the state change itself fails — DB error,
|
|
255
|
+
// user-row vanished, every tenant stream rejected — the token was never
|
|
256
|
+
// actually consumed. The handler releases the burn in those branches so
|
|
257
|
+
// the user can click the link again once ops restores state.
|
|
259
258
|
//
|
|
260
|
-
// Repro:
|
|
261
|
-
//
|
|
262
|
-
//
|
|
259
|
+
// Repro: delete the user READ-MODEL row (kumiko_events untouched) →
|
|
260
|
+
// loadValidatedUser returns null AFTER the burn → invalidToken + unburn.
|
|
261
|
+
// Re-insert the same row verbatim (restores version → optimistic write
|
|
262
|
+
// still matches the untouched event stream) → second attempt with the
|
|
263
|
+
// SAME token succeeds, proving the burn was released.
|
|
264
|
+
//
|
|
265
|
+
// (Deleting the membership no longer works as a failure trigger: the
|
|
266
|
+
// user aggregate stream lives in systemAdmin.tenantId and is recovered
|
|
267
|
+
// by resolveStreamTenants even with zero memberships — see the
|
|
268
|
+
// zero-membership-sysadmin test below.)
|
|
263
269
|
const seed = await seedUser({ email: "retry@example.com", password: "pw-retry-1234" });
|
|
264
270
|
const { token } = signResetToken(seed.id, 15, resetSecret);
|
|
265
271
|
|
|
266
|
-
await
|
|
272
|
+
const userRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
|
|
273
|
+
if (!userRow) throw new Error("seeded user row missing");
|
|
274
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}" WHERE id = $1`, [
|
|
275
|
+
seed.id,
|
|
276
|
+
]);
|
|
277
|
+
|
|
267
278
|
const firstAttempt = await post("/api/auth/reset-password", {
|
|
268
279
|
token,
|
|
269
280
|
newPassword: "never-lands-1234",
|
|
270
281
|
});
|
|
271
282
|
expect(firstAttempt.status).toBe(422);
|
|
272
283
|
|
|
273
|
-
// Re-insert the
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
tenantId: seed.tenantId,
|
|
277
|
-
roles: ["User"],
|
|
278
|
-
});
|
|
284
|
+
// Re-insert the captured row verbatim. Same userId, same version, same
|
|
285
|
+
// token still valid.
|
|
286
|
+
await insertOne(stack.db, userTable, userRow);
|
|
279
287
|
|
|
280
288
|
const secondAttempt = await post("/api/auth/reset-password", {
|
|
281
289
|
token,
|
|
@@ -284,6 +292,68 @@ describe("POST /auth/reset-password", () => {
|
|
|
284
292
|
expect(secondAttempt.status).toBe(200);
|
|
285
293
|
});
|
|
286
294
|
|
|
295
|
+
test("zero-membership sysadmin can still reset (stream recovered without any membership)", async () => {
|
|
296
|
+
// 205#1: a systemScope user whose stream lives in systemAdmin.tenantId
|
|
297
|
+
// (…0001) but who holds NO membership must still resolve. The stream-
|
|
298
|
+
// tenant recovery in resolveStreamTenants runs BEFORE the empty-
|
|
299
|
+
// membership check, so the reset targets …0001 and lands — instead of
|
|
300
|
+
// collapsing to invalid_token. Mirrors change-password's unconditional
|
|
301
|
+
// recovery.
|
|
302
|
+
const seed = await seedUser({ email: "lonely-admin@example.com", password: "pw-old-lonely!" });
|
|
303
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
304
|
+
|
|
305
|
+
// Confirm the stream tenant the recovery must find: the user aggregate
|
|
306
|
+
// was created via systemAdmin, so its stream lives in …0001.
|
|
307
|
+
const streamRows = (await asRawClient(stack.db).unsafe(
|
|
308
|
+
`SELECT "tenant_id" FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = 'user' ORDER BY "version" LIMIT 1`,
|
|
309
|
+
[seed.id],
|
|
310
|
+
)) as ReadonlyArray<{ tenant_id: string }>;
|
|
311
|
+
expect(streamRows[0]?.tenant_id).toBe(systemAdmin.tenantId);
|
|
312
|
+
|
|
313
|
+
const { token } = signResetToken(seed.id, 15, resetSecret);
|
|
314
|
+
const res = await post("/api/auth/reset-password", {
|
|
315
|
+
token,
|
|
316
|
+
newPassword: "pw-new-lonely-1234",
|
|
317
|
+
});
|
|
318
|
+
expect(res.status).toBe(200);
|
|
319
|
+
|
|
320
|
+
const row = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
|
|
321
|
+
expect(await verifyPassword(row?.["passwordHash"] as string, "pw-new-lonely-1234")).toBe(true);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("direct-inserted user with no stream + no membership → 422 (recovery stays bounded)", async () => {
|
|
325
|
+
// 205#1 "strikt sicher" boundary: a user inserted straight into the read
|
|
326
|
+
// model (no create-event → no event stream) with no membership has
|
|
327
|
+
// nothing to recover. resolveStreamTenants returns [] → invalidToken.
|
|
328
|
+
// Proves the fix only gains "empty memberships + recoverable stream",
|
|
329
|
+
// never blanket-opens zero-membership.
|
|
330
|
+
// Build a fully-populated user row with NO event stream: seed a normal
|
|
331
|
+
// user (gets all NOT-NULL columns), capture its row, then re-key it to a
|
|
332
|
+
// fresh id + email. getAggregateStreamTenant(orphanId) finds no events
|
|
333
|
+
// (the stream lives under the original id), and no membership is seeded
|
|
334
|
+
// → tenantOrder is empty.
|
|
335
|
+
const donor = await seedUser({ email: "donor@example.com", password: "donor-pw-1234" });
|
|
336
|
+
const donorRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === donor.id);
|
|
337
|
+
if (!donorRow) throw new Error("donor row missing");
|
|
338
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
339
|
+
|
|
340
|
+
const orphanId = "00000000-0000-4000-8000-0000000000ff";
|
|
341
|
+
await insertOne(stack.db, userTable, {
|
|
342
|
+
...donorRow,
|
|
343
|
+
id: orphanId,
|
|
344
|
+
email: "orphan@example.com",
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const { token } = signResetToken(orphanId, 15, resetSecret);
|
|
348
|
+
const res = await post("/api/auth/reset-password", {
|
|
349
|
+
token,
|
|
350
|
+
newPassword: "should-not-land-1234",
|
|
351
|
+
});
|
|
352
|
+
expect(res.status).toBe(422);
|
|
353
|
+
const body = await res.json();
|
|
354
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
|
|
355
|
+
});
|
|
356
|
+
|
|
287
357
|
test("replayed reset-token → 422 invalid_reset_token (single-use burn)", async () => {
|
|
288
358
|
// Reset tokens are single-use: the handler burns them in Redis via
|
|
289
359
|
// SETNX before the state change. First click wins; replay within TTL
|
|
@@ -141,11 +141,14 @@ async function loadValidatedUser(
|
|
|
141
141
|
return { ...me, version: me.version };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
// Loads the user's memberships and returns a prioritised tenant list
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
144
|
+
// Loads the user's memberships and returns a prioritised tenant list, with the
|
|
145
|
+
// aggregate's real stream tenant recovered from the event log prepended.
|
|
146
|
+
//
|
|
147
|
+
// Empty only when the user has NO memberships AND no recoverable stream tenant
|
|
148
|
+
// — the caller treats that as invalid_token. A zero-membership systemScope user
|
|
149
|
+
// whose stream lives outside any membership (a platform operator seeded under a
|
|
150
|
+
// fixture/SYSTEM tenant) still resolves, because the stream-tenant lookup runs
|
|
151
|
+
// before the empty check rather than being short-circuited by it.
|
|
149
152
|
async function resolveStreamTenants(
|
|
150
153
|
ctx: HandlerContext,
|
|
151
154
|
systemUser: SessionUser,
|
|
@@ -155,15 +158,16 @@ async function resolveStreamTenants(
|
|
|
155
158
|
userId: me.id,
|
|
156
159
|
})) as Array<{ tenantId: TenantId }>; // @cast-boundary db-runner
|
|
157
160
|
const ordered = orderTenantsByPreference(memberships, me.lastActiveTenantId);
|
|
158
|
-
if (ordered.length === 0) return [];
|
|
159
161
|
|
|
160
162
|
// The user aggregate is r.systemScope(): its event stream lives in whichever
|
|
161
163
|
// tenant the creating executor used, which need NOT be a membership tenant.
|
|
162
164
|
// A platform operator seeded under a fixture/platform tenant is the live case
|
|
163
165
|
// — its stream tenant is absent from `ordered`, so a membership-only search
|
|
164
166
|
// rejects every write and collapses to invalid_token. Recover the real stream
|
|
165
|
-
// tenant from the event log and try it first; memberships stay as fallback
|
|
166
|
-
//
|
|
167
|
+
// tenant from the event log and try it first; memberships stay as fallback.
|
|
168
|
+
// Pulled BEFORE the empty-membership check so a zero-membership operator whose
|
|
169
|
+
// stream lives in SYSTEM_TENANT is recoverable instead of collapsing to
|
|
170
|
+
// invalid_token — mirrors change-password.write.ts's unconditional recovery.
|
|
167
171
|
const streamTenant = await getAggregateStreamTenant(ctx.db.raw, me.id, USER_FEATURE);
|
|
168
172
|
if (streamTenant && !ordered.includes(streamTenant)) {
|
|
169
173
|
return [streamTenant, ...ordered];
|
|
@@ -1,22 +1,33 @@
|
|
|
1
|
-
// T1.5c — user-data-rights wiring for custom-fields
|
|
1
|
+
// T1.5c — user-data-rights wiring for custom-fields, exercised through the
|
|
2
|
+
// REAL export/forget runners (runUserExport / runForgetCleanup), not by
|
|
3
|
+
// calling the registered hooks in isolation.
|
|
2
4
|
//
|
|
3
|
-
//
|
|
4
|
-
// host entity:
|
|
5
|
+
// What the real runners prove that direct hook calls cannot:
|
|
5
6
|
//
|
|
6
|
-
// *
|
|
7
|
-
//
|
|
8
|
-
// `<entity>.customFields`.
|
|
7
|
+
// * runUserExport actually picks up the custom-fields export hook from the
|
|
8
|
+
// registry and folds its snippet into the user's cross-tenant bundle.
|
|
9
9
|
//
|
|
10
|
-
// *
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// * runForgetCleanup fires BOTH the host EXT_USER_DATA hook (owner-nulling
|
|
11
|
+
// anonymize) AND the custom-fields strip hook, in the order their declared
|
|
12
|
+
// `order` demands. The strip declares order -100 so it redacts sensitive
|
|
13
|
+
// jsonb keyed on `inserted_by_id` BEFORE the host hook nulls that column.
|
|
14
|
+
// If the ordering regressed, the host hook would null inserted_by_id first,
|
|
15
|
+
// the strip's `WHERE inserted_by_id = userId` would match 0 rows, and
|
|
16
|
+
// sensitive PII would silently survive (Art. 17 violation). Calling the
|
|
17
|
+
// hooks by hand never exercised this interaction.
|
|
13
18
|
//
|
|
14
|
-
// *
|
|
15
|
-
//
|
|
19
|
+
// * The anonymize-vs-delete strategy comes from the data-retention policy:
|
|
20
|
+
// no override → strategy "delete" (custom-fields strip is a no-op, host
|
|
21
|
+
// deletes the row); per-tenant anonymize override → strategy "anonymize"
|
|
22
|
+
// (strip runs, host nulls the owner).
|
|
16
23
|
|
|
17
24
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
19
|
-
import {
|
|
25
|
+
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
26
|
+
import {
|
|
27
|
+
buildEntityTable,
|
|
28
|
+
createEventStoreExecutor,
|
|
29
|
+
createTenantDb,
|
|
30
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
20
31
|
import {
|
|
21
32
|
createEntity,
|
|
22
33
|
createEntityExecutor,
|
|
@@ -32,19 +43,28 @@ import {
|
|
|
32
43
|
resetEventStore,
|
|
33
44
|
setupTestStack,
|
|
34
45
|
type TestStack,
|
|
46
|
+
TestUsers,
|
|
35
47
|
unsafeCreateEntityTable,
|
|
36
48
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
49
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
37
50
|
import { z } from "zod";
|
|
38
51
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
39
|
-
import { createDataRetentionFeature } from "../../data-retention";
|
|
52
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
53
|
+
import { tenantRetentionOverrideTable } from "../../data-retention/schema/tenant-retention-override";
|
|
40
54
|
import { createSessionsFeature } from "../../sessions";
|
|
41
|
-
import { createUserFeature, userEntity } from "../../user";
|
|
55
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
42
56
|
import { createUserDataRightsFeature } from "../../user-data-rights";
|
|
57
|
+
import { runForgetCleanup } from "../../user-data-rights/run-forget-cleanup";
|
|
58
|
+
import { runUserExport } from "../../user-data-rights/run-user-export";
|
|
43
59
|
import { fieldDefinitionEntity } from "../entity";
|
|
44
60
|
import { createCustomFieldsFeature } from "../feature";
|
|
45
61
|
import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
46
62
|
import { wireCustomFieldsUserDataRightsFor } from "../wire-user-data-rights";
|
|
47
63
|
|
|
64
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
65
|
+
const NOW = (): Instant => getTemporal().Now.instant();
|
|
66
|
+
const PAST = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
67
|
+
|
|
48
68
|
const propertyEntity = createEntity({
|
|
49
69
|
table: "read_t15c_properties",
|
|
50
70
|
fields: {
|
|
@@ -55,15 +75,12 @@ const propertyEntity = createEntity({
|
|
|
55
75
|
const propertyTable = buildEntityTable("property", propertyEntity);
|
|
56
76
|
|
|
57
77
|
// Host entity gets its own EXT_USER_DATA-registration too — that's the
|
|
58
|
-
// canonical setup
|
|
59
|
-
//
|
|
60
|
-
// same
|
|
78
|
+
// canonical setup. The host's anonymize hook NULLS inserted_by_id (default
|
|
79
|
+
// order 0); the custom-fields strip (order -100) must run first. Both fire in
|
|
80
|
+
// the same runForgetCleanup sub-transaction.
|
|
61
81
|
const hostExportHook: UserDataExportHook = async (ctx) => {
|
|
62
82
|
const rows = await asRawClient(ctx.db).unsafe(
|
|
63
|
-
`
|
|
64
|
-
SELECT id, name FROM read_t15c_properties
|
|
65
|
-
WHERE inserted_by_id = $1 AND tenant_id = $2
|
|
66
|
-
`,
|
|
83
|
+
`SELECT id, name FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
67
84
|
[ctx.userId, ctx.tenantId],
|
|
68
85
|
);
|
|
69
86
|
const list = rows as ReadonlyArray<Record<string, unknown>>;
|
|
@@ -77,19 +94,15 @@ const hostExportHook: UserDataExportHook = async (ctx) => {
|
|
|
77
94
|
const hostDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
78
95
|
if (strategy === "delete") {
|
|
79
96
|
await asRawClient(ctx.db).unsafe(
|
|
80
|
-
`
|
|
81
|
-
DELETE FROM read_t15c_properties
|
|
82
|
-
WHERE inserted_by_id = $1 AND tenant_id = $2
|
|
83
|
-
`,
|
|
97
|
+
`DELETE FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
84
98
|
[ctx.userId, ctx.tenantId],
|
|
85
99
|
);
|
|
86
100
|
} else {
|
|
87
|
-
// anonymize: clear owner, keep row + non-sensitive customFields
|
|
101
|
+
// anonymize: clear owner, keep row + non-sensitive customFields. Runs AFTER
|
|
102
|
+
// the custom-fields strip (order -100 < 0) — if it ran first, the strip's
|
|
103
|
+
// owner-keyed WHERE would match nothing.
|
|
88
104
|
await asRawClient(ctx.db).unsafe(
|
|
89
|
-
`
|
|
90
|
-
UPDATE read_t15c_properties SET inserted_by_id = NULL
|
|
91
|
-
WHERE inserted_by_id = $1 AND tenant_id = $2
|
|
92
|
-
`,
|
|
105
|
+
`UPDATE read_t15c_properties SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
93
106
|
[ctx.userId, ctx.tenantId],
|
|
94
107
|
);
|
|
95
108
|
}
|
|
@@ -125,8 +138,10 @@ const propertyFeature = defineFeature("property-t15c", (r) => {
|
|
|
125
138
|
|
|
126
139
|
const customFieldsFeature = createCustomFieldsFeature();
|
|
127
140
|
const admin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
|
|
141
|
+
const TENANT = admin.tenantId;
|
|
128
142
|
|
|
129
143
|
let stack: TestStack;
|
|
144
|
+
let overrideExecutor: ReturnType<typeof createEventStoreExecutor>;
|
|
130
145
|
|
|
131
146
|
beforeAll(async () => {
|
|
132
147
|
stack = await setupTestStack({
|
|
@@ -143,7 +158,34 @@ beforeAll(async () => {
|
|
|
143
158
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
144
159
|
await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
|
|
145
160
|
await unsafeCreateEntityTable(stack.db, propertyEntity);
|
|
161
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
146
162
|
await createEventsTable(stack.db);
|
|
163
|
+
|
|
164
|
+
// runForgetCleanup + runUserExport iterate the user's memberships. Provide a
|
|
165
|
+
// minimal membership read-model (same shape the user-data-rights suite uses).
|
|
166
|
+
await asRawClient(stack.db).unsafe(`
|
|
167
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
168
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
169
|
+
tenant_id UUID NOT NULL,
|
|
170
|
+
user_id TEXT NOT NULL,
|
|
171
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
172
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
173
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
174
|
+
inserted_by_id TEXT,
|
|
175
|
+
modified_by_id TEXT,
|
|
176
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
177
|
+
deleted_at TIMESTAMPTZ,
|
|
178
|
+
deleted_by_id TEXT,
|
|
179
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
180
|
+
UNIQUE(user_id, tenant_id)
|
|
181
|
+
)
|
|
182
|
+
`);
|
|
183
|
+
|
|
184
|
+
overrideExecutor = createEventStoreExecutor(
|
|
185
|
+
tenantRetentionOverrideTable,
|
|
186
|
+
tenantRetentionOverrideEntity,
|
|
187
|
+
{ entityName: "tenant-retention-override" },
|
|
188
|
+
);
|
|
147
189
|
});
|
|
148
190
|
|
|
149
191
|
afterAll(async () => {
|
|
@@ -154,6 +196,9 @@ beforeEach(async () => {
|
|
|
154
196
|
await resetEventStore(stack);
|
|
155
197
|
await asRawClient(stack.db).unsafe(`DELETE FROM read_t15c_properties`);
|
|
156
198
|
await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
|
|
199
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
200
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
|
|
201
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantRetentionOverrideTable.tableName}"`);
|
|
157
202
|
});
|
|
158
203
|
|
|
159
204
|
async function defineField(fieldKey: string, serializedField: Record<string, unknown>) {
|
|
@@ -185,43 +230,78 @@ async function setField(entityId: string, fieldKey: string, value: unknown) {
|
|
|
185
230
|
|
|
186
231
|
async function readRow(id: string): Promise<Record<string, unknown> | undefined> {
|
|
187
232
|
const rows = await asRawClient(stack.db).unsafe(
|
|
188
|
-
`SELECT id, custom_fields FROM read_t15c_properties WHERE id = $1`,
|
|
233
|
+
`SELECT id, custom_fields, inserted_by_id FROM read_t15c_properties WHERE id = $1`,
|
|
189
234
|
[id],
|
|
190
235
|
);
|
|
191
236
|
const list = rows as ReadonlyArray<Record<string, unknown>>;
|
|
192
237
|
return list[0];
|
|
193
238
|
}
|
|
194
239
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
240
|
+
// Seed the acting admin as a normal active user with a membership in TENANT so
|
|
241
|
+
// the export runner iterates their data. Status active = NOT picked up by
|
|
242
|
+
// runForgetCleanup.
|
|
243
|
+
async function seedActiveUserWithMembership(): Promise<void> {
|
|
244
|
+
await insertOne(stack.db, userTable, {
|
|
245
|
+
id: admin.id,
|
|
246
|
+
tenantId: TENANT,
|
|
247
|
+
email: `admin@example.com`,
|
|
248
|
+
passwordHash: "hashed",
|
|
249
|
+
displayName: "Admin",
|
|
250
|
+
locale: "de",
|
|
251
|
+
emailVerified: true,
|
|
252
|
+
roles: '["TenantAdmin"]',
|
|
253
|
+
status: USER_STATUS.Active,
|
|
254
|
+
});
|
|
255
|
+
await seedMembership();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Seed the acting admin as DeletionRequested + grace expired so
|
|
259
|
+
// runForgetCleanup picks them up, with a membership in TENANT.
|
|
260
|
+
async function seedForgetUserWithMembership(): Promise<void> {
|
|
261
|
+
await insertOne(stack.db, userTable, {
|
|
262
|
+
id: admin.id,
|
|
263
|
+
tenantId: TENANT,
|
|
264
|
+
email: `admin@example.com`,
|
|
265
|
+
passwordHash: "hashed",
|
|
266
|
+
displayName: "Admin",
|
|
267
|
+
locale: "de",
|
|
268
|
+
emailVerified: true,
|
|
269
|
+
roles: '["TenantAdmin"]',
|
|
270
|
+
status: USER_STATUS.DeletionRequested,
|
|
271
|
+
gracePeriodEnd: PAST(),
|
|
272
|
+
});
|
|
273
|
+
await seedMembership();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function seedMembership(): Promise<void> {
|
|
277
|
+
await asRawClient(stack.db).unsafe(
|
|
278
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
|
|
279
|
+
VALUES ($1, $2, '["TenantAdmin"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
|
|
280
|
+
[TENANT, admin.id],
|
|
203
281
|
);
|
|
204
|
-
if (!customFieldsUsage) throw new Error("custom-fields user-data-rights export hook not found");
|
|
205
|
-
const hook = (customFieldsUsage.options as { export: UserDataExportHook }).export;
|
|
206
|
-
return hook({ db: stack.db, tenantId, userId });
|
|
207
282
|
}
|
|
208
283
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
(
|
|
284
|
+
// Set a per-tenant retention override for the property entity through the same
|
|
285
|
+
// event-store path the forget resolver reads — no test-only shortcut.
|
|
286
|
+
async function seedPropertyAnonymizeOverride(): Promise<void> {
|
|
287
|
+
const by = { ...TestUsers.systemAdmin, tenantId: TENANT };
|
|
288
|
+
const result = await overrideExecutor.create(
|
|
289
|
+
{
|
|
290
|
+
entityName: "property",
|
|
291
|
+
config: JSON.stringify({ keepFor: "30d", strategy: "anonymize" }),
|
|
292
|
+
reason: "test",
|
|
293
|
+
tenantId: TENANT,
|
|
294
|
+
},
|
|
295
|
+
by,
|
|
296
|
+
createTenantDb(stack.db, TENANT, "system"),
|
|
217
297
|
);
|
|
218
|
-
if (!
|
|
219
|
-
|
|
220
|
-
return hook({ db: stack.db, tenantId, userId }, strategy);
|
|
298
|
+
if (!result.isSuccess)
|
|
299
|
+
throw new Error(`seedPropertyAnonymizeOverride failed: ${JSON.stringify(result)}`);
|
|
221
300
|
}
|
|
222
301
|
|
|
223
|
-
describe("T1.5c: user-data-rights
|
|
224
|
-
test("export: customFields jsonb
|
|
302
|
+
describe("T1.5c: custom-fields user-data-rights through the real runners", () => {
|
|
303
|
+
test("export: customFields jsonb lands in the user's export bundle", async () => {
|
|
304
|
+
await seedActiveUserWithMembership();
|
|
225
305
|
const propertyId = "11111111-1111-4000-8000-000000000001";
|
|
226
306
|
await defineField("email", { type: "text", sensitive: true });
|
|
227
307
|
await defineField("vipFlag", { type: "boolean" });
|
|
@@ -230,17 +310,27 @@ describe("T1.5c: user-data-rights wiring for custom-fields", () => {
|
|
|
230
310
|
await setField(propertyId, "vipFlag", true);
|
|
231
311
|
await stack.eventDispatcher?.runOnce();
|
|
232
312
|
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
313
|
+
const bundle = await runUserExport({
|
|
314
|
+
db: stack.db,
|
|
315
|
+
registry: stack.registry,
|
|
316
|
+
userId: admin.id,
|
|
317
|
+
now: NOW(),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const tenantSection = bundle.tenants.find((t) => t.tenantId === TENANT);
|
|
321
|
+
expect(tenantSection).toBeDefined();
|
|
322
|
+
const cfSnippet = tenantSection?.entities.find((e) => e.entity === "property.customFields");
|
|
323
|
+
expect(cfSnippet).toBeDefined();
|
|
324
|
+
expect(cfSnippet?.rows).toHaveLength(1);
|
|
325
|
+
expect(cfSnippet?.rows[0]?.["customFields"]).toMatchObject({
|
|
238
326
|
email: "alice@example.com",
|
|
239
327
|
vipFlag: true,
|
|
240
328
|
});
|
|
241
329
|
});
|
|
242
330
|
|
|
243
|
-
test("forget anonymize: sensitive
|
|
331
|
+
test("forget anonymize: strip runs BEFORE host owner-nulling → sensitive key gone, non-sensitive kept", async () => {
|
|
332
|
+
await seedForgetUserWithMembership();
|
|
333
|
+
await seedPropertyAnonymizeOverride();
|
|
244
334
|
const propertyId = "22222222-2222-4000-8000-000000000002";
|
|
245
335
|
await defineField("email", { type: "text", sensitive: true });
|
|
246
336
|
await defineField("vipFlag", { type: "boolean" });
|
|
@@ -249,50 +339,81 @@ describe("T1.5c: user-data-rights wiring for custom-fields", () => {
|
|
|
249
339
|
await setField(propertyId, "vipFlag", true);
|
|
250
340
|
await stack.eventDispatcher?.runOnce();
|
|
251
341
|
|
|
252
|
-
await
|
|
342
|
+
const result = await runForgetCleanup({
|
|
343
|
+
db: stack.db,
|
|
344
|
+
registry: stack.registry,
|
|
345
|
+
now: NOW(),
|
|
346
|
+
});
|
|
347
|
+
expect(result.processedUserIds).toContain(admin.id);
|
|
348
|
+
expect(result.errors).toHaveLength(0);
|
|
253
349
|
|
|
254
350
|
const row = await readRow(propertyId);
|
|
351
|
+
// Host hook ran (owner nulled), and the strip ran BEFORE it (sensitive
|
|
352
|
+
// key removed despite the owner-keyed WHERE — proof of the -100 ordering).
|
|
353
|
+
expect(row?.["inserted_by_id"]).toBeNull();
|
|
255
354
|
const customFields = row?.["custom_fields"] as Record<string, unknown> | undefined;
|
|
256
355
|
expect(customFields).toBeDefined();
|
|
257
356
|
expect(customFields).not.toHaveProperty("email");
|
|
258
357
|
expect(customFields).toMatchObject({ vipFlag: true });
|
|
259
358
|
});
|
|
260
359
|
|
|
261
|
-
test("forget delete
|
|
360
|
+
test("forget delete (no override → strategy delete): host removes the row, strip is a no-op", async () => {
|
|
361
|
+
await seedForgetUserWithMembership();
|
|
362
|
+
// No retention override → policyToStrategy(null) = "delete".
|
|
262
363
|
const propertyId = "33333333-3333-4000-8000-000000000003";
|
|
263
364
|
await defineField("email", { type: "text", sensitive: true });
|
|
264
365
|
await createProperty(propertyId, "Delete-Me");
|
|
265
366
|
await setField(propertyId, "email", "alice@example.com");
|
|
266
367
|
await stack.eventDispatcher?.runOnce();
|
|
267
368
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
369
|
+
const result = await runForgetCleanup({
|
|
370
|
+
db: stack.db,
|
|
371
|
+
registry: stack.registry,
|
|
372
|
+
now: NOW(),
|
|
373
|
+
});
|
|
374
|
+
expect(result.processedUserIds).toContain(admin.id);
|
|
272
375
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
expect(
|
|
376
|
+
// Host delete-hook removed the row; custom-fields strip stayed out of the
|
|
377
|
+
// way (it returns early on strategy=delete).
|
|
378
|
+
expect(await readRow(propertyId)).toBeUndefined();
|
|
276
379
|
});
|
|
277
380
|
|
|
278
381
|
test("export: rows without customFields are not included in the snippet", async () => {
|
|
382
|
+
await seedActiveUserWithMembership();
|
|
279
383
|
const propertyId = "44444444-4444-4000-8000-000000000004";
|
|
280
384
|
await createProperty(propertyId, "NoCustomFields");
|
|
385
|
+
await stack.eventDispatcher?.runOnce();
|
|
281
386
|
|
|
282
|
-
const
|
|
283
|
-
|
|
387
|
+
const bundle = await runUserExport({
|
|
388
|
+
db: stack.db,
|
|
389
|
+
registry: stack.registry,
|
|
390
|
+
userId: admin.id,
|
|
391
|
+
now: NOW(),
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const tenantSection = bundle.tenants.find((t) => t.tenantId === TENANT);
|
|
395
|
+
const cfSnippet = tenantSection?.entities.find((e) => e.entity === "property.customFields");
|
|
396
|
+
expect(cfSnippet).toBeUndefined();
|
|
284
397
|
});
|
|
285
398
|
|
|
286
|
-
test("anonymize without sensitive fields defined
|
|
399
|
+
test("forget anonymize without sensitive fields defined → all customFields kept", async () => {
|
|
400
|
+
await seedForgetUserWithMembership();
|
|
401
|
+
await seedPropertyAnonymizeOverride();
|
|
287
402
|
const propertyId = "55555555-5555-4000-8000-000000000005";
|
|
288
403
|
await defineField("nonSensitive", { type: "text" });
|
|
289
404
|
await createProperty(propertyId, "AllStay");
|
|
290
405
|
await setField(propertyId, "nonSensitive", "still-here");
|
|
291
406
|
await stack.eventDispatcher?.runOnce();
|
|
292
407
|
|
|
293
|
-
await
|
|
408
|
+
const result = await runForgetCleanup({
|
|
409
|
+
db: stack.db,
|
|
410
|
+
registry: stack.registry,
|
|
411
|
+
now: NOW(),
|
|
412
|
+
});
|
|
413
|
+
expect(result.processedUserIds).toContain(admin.id);
|
|
294
414
|
|
|
295
415
|
const row = await readRow(propertyId);
|
|
416
|
+
expect(row?.["inserted_by_id"]).toBeNull();
|
|
296
417
|
expect((row?.["custom_fields"] as Record<string, unknown>)?.["nonSensitive"]).toBe(
|
|
297
418
|
"still-here",
|
|
298
419
|
);
|
|
@@ -11,6 +11,6 @@ export async function selectSerializedFieldDefinition(
|
|
|
11
11
|
"SELECT serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND field_key = $2 AND tenant_id = $3 LIMIT 1",
|
|
12
12
|
[entityName, fieldKey, tenantId],
|
|
13
13
|
);
|
|
14
|
-
const first = (rows as ReadonlyArray<Record<string, unknown>>)[0];
|
|
14
|
+
const first = (rows as ReadonlyArray<Record<string, unknown>>)[0]; // @cast-boundary db-row
|
|
15
15
|
return first ? (first["serialized_field"] ?? null) : null;
|
|
16
16
|
}
|
|
@@ -6,7 +6,7 @@ export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string
|
|
|
6
6
|
"SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1",
|
|
7
7
|
[tenantId],
|
|
8
8
|
);
|
|
9
|
-
const rows = rowsResult as ReadonlyArray<Record<string, unknown>>;
|
|
9
|
+
const rows = rowsResult as ReadonlyArray<Record<string, unknown>>; // @cast-boundary db-row
|
|
10
10
|
const first = rows[0];
|
|
11
11
|
if (!first) return 0;
|
|
12
12
|
const n = first["n"];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// feature.ts contract tests for file-provider-inmemory.
|
|
2
2
|
|
|
3
3
|
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { FileProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
|
|
4
5
|
import { clearStorage, fileProviderInMemoryFeature, listKeys } from "../feature";
|
|
5
6
|
|
|
6
7
|
describe("fileProviderInMemoryFeature — shape", () => {
|
|
@@ -33,3 +34,57 @@ describe("listKeys / clearStorage — per-tenant store helpers", () => {
|
|
|
33
34
|
expect(() => clearStorage("never-touched")).not.toThrow();
|
|
34
35
|
});
|
|
35
36
|
});
|
|
37
|
+
|
|
38
|
+
// extension-usage `options` is engine-payload (unknown) — structurally validate
|
|
39
|
+
// instead of casting blind.
|
|
40
|
+
function isFileProviderPlugin(o: unknown): o is FileProviderPlugin {
|
|
41
|
+
return typeof o === "object" && o !== null && "build" in o && typeof o.build === "function";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function inmemoryPlugin(): FileProviderPlugin {
|
|
45
|
+
const options = fileProviderInMemoryFeature.extensionUsages.find(
|
|
46
|
+
(u) => u.extensionName === "fileProvider" && u.entityName === "inmemory",
|
|
47
|
+
)?.options;
|
|
48
|
+
if (!isFileProviderPlugin(options)) {
|
|
49
|
+
throw new Error("file-provider-inmemory: inmemory plugin not registered with a build()");
|
|
50
|
+
}
|
|
51
|
+
return options;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const bytes = (s: string) => new TextEncoder().encode(s);
|
|
55
|
+
|
|
56
|
+
describe("file-provider-inmemory — build() + per-tenant store", () => {
|
|
57
|
+
test("build liefert Provider; Write erscheint in listKeys(tenant)", async () => {
|
|
58
|
+
const provider = await inmemoryPlugin().build({}, "tenant-build-1");
|
|
59
|
+
await provider.write("doc.txt", bytes("x"));
|
|
60
|
+
expect(listKeys("tenant-build-1")).toContain("doc.txt");
|
|
61
|
+
clearStorage("tenant-build-1");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("selber Tenant: zwei builds liefern identitätsstabilen Storage (State bleibt)", async () => {
|
|
65
|
+
const a = await inmemoryPlugin().build({}, "tenant-stable");
|
|
66
|
+
await a.write("first.txt", bytes("1"));
|
|
67
|
+
const b = await inmemoryPlugin().build({}, "tenant-stable");
|
|
68
|
+
expect(await b.exists("first.txt")).toBe(true);
|
|
69
|
+
clearStorage("tenant-stable");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("Tenant-Isolation: Write in A erscheint nicht in B", async () => {
|
|
73
|
+
const a = await inmemoryPlugin().build({}, "tenant-iso-a");
|
|
74
|
+
const b = await inmemoryPlugin().build({}, "tenant-iso-b");
|
|
75
|
+
await a.write("only-in-a.txt", bytes("a"));
|
|
76
|
+
expect(listKeys("tenant-iso-a")).toContain("only-in-a.txt");
|
|
77
|
+
expect(listKeys("tenant-iso-b")).not.toContain("only-in-a.txt");
|
|
78
|
+
expect(await b.exists("only-in-a.txt")).toBe(false);
|
|
79
|
+
clearStorage("tenant-iso-a");
|
|
80
|
+
clearStorage("tenant-iso-b");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("clearStorage leert den Tenant-Store", async () => {
|
|
84
|
+
const p = await inmemoryPlugin().build({}, "tenant-clear");
|
|
85
|
+
await p.write("gone.txt", bytes("x"));
|
|
86
|
+
expect(listKeys("tenant-clear")).toHaveLength(1);
|
|
87
|
+
clearStorage("tenant-clear");
|
|
88
|
+
expect(listKeys("tenant-clear")).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// feature.ts contract tests for file-provider-s3.
|
|
2
2
|
|
|
3
3
|
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { FileProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
|
|
4
5
|
import { fileProviderS3Feature, S3_SECRET_ACCESS_KEY } from "../feature";
|
|
5
6
|
|
|
6
7
|
describe("fileProviderS3Feature — shape", () => {
|
|
@@ -52,3 +53,29 @@ describe("fileProviderS3Feature — plugin-registration", () => {
|
|
|
52
53
|
);
|
|
53
54
|
});
|
|
54
55
|
});
|
|
56
|
+
|
|
57
|
+
// extension-usage `options` is engine-payload (unknown) — structurally validate.
|
|
58
|
+
function isFileProviderPlugin(o: unknown): o is FileProviderPlugin {
|
|
59
|
+
return typeof o === "object" && o !== null && "build" in o && typeof o.build === "function";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function s3Plugin(): FileProviderPlugin {
|
|
63
|
+
const options = fileProviderS3Feature.extensionUsages.find(
|
|
64
|
+
(u) => u.extensionName === "fileProvider" && u.entityName === "s3",
|
|
65
|
+
)?.options;
|
|
66
|
+
if (!isFileProviderPlugin(options)) {
|
|
67
|
+
throw new Error("file-provider-s3: s3 plugin not registered with a build()");
|
|
68
|
+
}
|
|
69
|
+
return options;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("fileProviderS3Feature — build() guard", () => {
|
|
73
|
+
test("build ohne ctx.config wirft (config-feature nicht gemountet)", async () => {
|
|
74
|
+
// Die tieferen Value-/Secret-Pfade (bucket/region/accessKeyId leer,
|
|
75
|
+
// secret fehlt, Happy-Path) hängen an der echten Config-/Secrets-
|
|
76
|
+
// Pipeline → S3-Integration-Test (setupTestStack + MinIO), nicht hier.
|
|
77
|
+
// Die requireNonEmpty-Werteprüfung selbst ist über
|
|
78
|
+
// foundation-shared/config-helpers abgedeckt.
|
|
79
|
+
await expect(s3Plugin().build({}, "tenant-x")).rejects.toThrow("ctx.config is missing");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -111,17 +111,45 @@ describe("s3-provider (Minio)", () => {
|
|
|
111
111
|
await expect(provider.read(key)).rejects.toThrow();
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
test("writeStream round-trip via multipart writer preserves
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
114
|
+
test("writeStream round-trip via multipart writer preserves byte-exact ordering", async () => {
|
|
115
|
+
// Regression guard for the multipart-flush bug: the source chunks are
|
|
116
|
+
// deliberately NON-ALIGNED to the 5 MiB part boundary ([3,3,2] MiB → the
|
|
117
|
+
// internal part split at 5 MiB lands mid-chunk #2). The old
|
|
118
|
+
// `buffered >= STREAM_PART_SIZE` flush would emit a non-final part of an
|
|
119
|
+
// odd size at that boundary; the Bun-writer (partSize) path produces the
|
|
120
|
+
// correct part topology. Either way we verify END-TO-END byte integrity,
|
|
121
|
+
// not just total size + first/last byte.
|
|
122
|
+
//
|
|
123
|
+
// Each chunk carries a chunk-distinct content pattern (incl. a per-chunk
|
|
124
|
+
// marker in byte[0]). A re-order, dropped, or duplicated part therefore
|
|
125
|
+
// changes the readback SHA256 — a same-pattern-per-part test would not
|
|
126
|
+
// catch that. We assert both the SHA256 over the whole stream AND the
|
|
127
|
+
// per-chunk-offset marker bytes.
|
|
128
|
+
//
|
|
129
|
+
// NOTE: MinIO does NOT enforce the AWS `MinPartSize` (5 MiB non-final
|
|
130
|
+
// part) rule, so this test cannot reproduce the genuine S3 `EntityTooSmall`
|
|
131
|
+
// rejection — that needs a manual smoke against AWS/R2. What it DOES guard
|
|
132
|
+
// is byte-ordering/integrity of the multipart round-trip, which is
|
|
133
|
+
// provider-agnostic.
|
|
119
134
|
const key = uniqueKey("stream-multipart.bin");
|
|
120
135
|
const partSize = 5 * 1024 * 1024;
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
136
|
+
const MiB = 1024 * 1024;
|
|
137
|
+
const chunkSizes = [3 * MiB, 3 * MiB, 2 * MiB]; // 8 MiB total, non-aligned to 5 MiB
|
|
138
|
+
|
|
139
|
+
function makeChunk(index: number, size: number): Uint8Array {
|
|
140
|
+
const c = new Uint8Array(size);
|
|
141
|
+
// byte[0] = chunk marker; remaining bytes mix index + position so each
|
|
142
|
+
// chunk's body is distinct (reorder/duplicate changes the hash).
|
|
143
|
+
c[0] = index;
|
|
144
|
+
for (let i = 1; i < size; i++) c[i] = (index * 31 + i) % 251;
|
|
145
|
+
return c;
|
|
146
|
+
}
|
|
147
|
+
const chunks = chunkSizes.map((size, i) => makeChunk(i, size));
|
|
148
|
+
|
|
149
|
+
// Expected hash over the concatenated source.
|
|
150
|
+
const sourceHasher = new Bun.CryptoHasher("sha256");
|
|
151
|
+
for (const c of chunks) sourceHasher.update(c);
|
|
152
|
+
const expectedHash = sourceHasher.digest("hex");
|
|
125
153
|
|
|
126
154
|
if (!provider.writeStream) throw new Error("s3 provider should implement writeStream");
|
|
127
155
|
await provider.writeStream(
|
|
@@ -132,10 +160,24 @@ describe("s3-provider (Minio)", () => {
|
|
|
132
160
|
);
|
|
133
161
|
|
|
134
162
|
const readBack = await provider.read(key);
|
|
135
|
-
|
|
163
|
+
const totalSize = chunkSizes.reduce((a, b) => a + b, 0);
|
|
164
|
+
expect(readBack.byteLength).toBe(totalSize);
|
|
136
165
|
expect(readBack.byteLength).toBeGreaterThan(partSize);
|
|
137
|
-
|
|
138
|
-
|
|
166
|
+
|
|
167
|
+
// Byte-exact integrity over the full stream — catches any mid-stream
|
|
168
|
+
// corruption / reorder / off-by-part the size+endpoints check would miss.
|
|
169
|
+
const readHasher = new Bun.CryptoHasher("sha256");
|
|
170
|
+
readHasher.update(readBack);
|
|
171
|
+
expect(readHasher.digest("hex")).toBe(expectedHash);
|
|
172
|
+
|
|
173
|
+
// Explicit per-chunk marker check at the expected source offsets — proves
|
|
174
|
+
// the parts landed in order (not just that the bytes are collectively
|
|
175
|
+
// present).
|
|
176
|
+
let offset = 0;
|
|
177
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
178
|
+
expect(readBack[offset]).toBe(i);
|
|
179
|
+
offset += chunkSizes[i] ?? 0;
|
|
180
|
+
}
|
|
139
181
|
});
|
|
140
182
|
});
|
|
141
183
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// config-helpers Unit-Tests (Phase 1, test-luecken-integration).
|
|
2
|
+
//
|
|
3
|
+
// Pinnt das Verhalten der von ai-/mail-/file-foundation geteilten
|
|
4
|
+
// Narrowing-Helfer — inkl. der non-obvious Grenzfälle: requireDefined
|
|
5
|
+
// narrowt NUR `undefined` (nicht falsy), und requireNonEmpty hat zwei
|
|
6
|
+
// getrennte Fehlerpfade (undefined vs leer), die über die Error-Message
|
|
7
|
+
// unterschieden werden.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { requireDefined, requireNonEmpty } from "../config-helpers";
|
|
11
|
+
|
|
12
|
+
describe("requireDefined", () => {
|
|
13
|
+
test("undefined → wirft mit featureName + label + Misconfig-Hinweis", () => {
|
|
14
|
+
expect(() => requireDefined(undefined, "ai-foundation", "apiKey")).toThrow(
|
|
15
|
+
"ai-foundation: 'apiKey' config key resolved to undefined — registry misconfigured (no value + no default)",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("defined Wert → unverändert zurück", () => {
|
|
20
|
+
expect(requireDefined("sk-123", "ai-foundation", "apiKey")).toBe("sk-123");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("falsy-aber-defined Werte passieren (Check ist === undefined, nicht falsy)", () => {
|
|
24
|
+
// Würde hier ein falsy-Check stehen, bräche ein numerischer Key mit Wert 0
|
|
25
|
+
// oder ein leerer-String-Default. requireNonEmpty ist der strengere Helfer.
|
|
26
|
+
expect(requireDefined(0, "f", "n")).toBe(0);
|
|
27
|
+
expect(requireDefined("", "f", "n")).toBe("");
|
|
28
|
+
expect(requireDefined(false, "f", "n")).toBe(false);
|
|
29
|
+
expect(requireDefined(null, "f", "n")).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("Objekt-Wert → identische Referenz zurück (generischer Typ erhalten)", () => {
|
|
33
|
+
const cfg = { host: "smtp.example.com", port: 587 };
|
|
34
|
+
expect(requireDefined(cfg, "mail-foundation", "smtp")).toBe(cfg);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("requireNonEmpty", () => {
|
|
39
|
+
test("undefined → wirft die requireDefined-Message (delegiert, NICHT empty-Pfad)", () => {
|
|
40
|
+
expect(() => requireNonEmpty(undefined, "mail-foundation", "host")).toThrow(
|
|
41
|
+
"config key resolved to undefined",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("leerer String → wirft empty-Message mit Default-uiHint", () => {
|
|
46
|
+
expect(() => requireNonEmpty("", "file-foundation", "bucket")).toThrow(
|
|
47
|
+
"file-foundation: 'bucket' is empty — tenant must configure it before use. Set via tenant-admin UI or seed-handler.",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("leerer String mit custom uiHint → Hint landet in der Message", () => {
|
|
52
|
+
expect(() =>
|
|
53
|
+
requireNonEmpty("", "ai-foundation", "model", "Choose a model in Settings → AI."),
|
|
54
|
+
).toThrow("is empty — tenant must configure it before use. Choose a model in Settings → AI.");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("non-empty Wert → unverändert zurück", () => {
|
|
58
|
+
expect(requireNonEmpty("smtp.example.com", "mail-foundation", "host")).toBe("smtp.example.com");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("reiner Whitespace → wirft empty-Message (getrimmt, gilt als leer)", () => {
|
|
62
|
+
expect(() => requireNonEmpty(" ", "mail-foundation", "host")).toThrow(
|
|
63
|
+
"mail-foundation: 'host' is empty — tenant must configure it before use.",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("umgebender Whitespace wird vom Rückgabewert getrimmt", () => {
|
|
68
|
+
expect(requireNonEmpty(" smtp.example.com ", "mail-foundation", "host")).toBe(
|
|
69
|
+
"smtp.example.com",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -50,6 +50,10 @@ export function requireDefined<T>(value: T | undefined, featureName: string, lab
|
|
|
50
50
|
* Typical use: SMTP host, S3 bucket, model id — values without which the
|
|
51
51
|
* downstream SDK would 400 with a cryptic message. The clearer "tenant
|
|
52
52
|
* must configure X via tenant-admin UI" lands at the call-site instead.
|
|
53
|
+
*
|
|
54
|
+
* Whitespace is trimmed: a whitespace-only value counts as empty, and the
|
|
55
|
+
* returned string has surrounding whitespace removed — so a stray " host "
|
|
56
|
+
* never reaches the SDK as-is.
|
|
53
57
|
*/
|
|
54
58
|
export function requireNonEmpty(
|
|
55
59
|
value: string | undefined,
|
|
@@ -57,11 +61,11 @@ export function requireNonEmpty(
|
|
|
57
61
|
label: string,
|
|
58
62
|
uiHint = "Set via tenant-admin UI or seed-handler.",
|
|
59
63
|
): string {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
64
|
+
const trimmed = requireDefined(value, featureName, label).trim();
|
|
65
|
+
if (trimmed.length === 0) {
|
|
62
66
|
throw new Error(
|
|
63
67
|
`${featureName}: '${label}' is empty — tenant must configure it before use. ${uiHint}`,
|
|
64
68
|
);
|
|
65
69
|
}
|
|
66
|
-
return
|
|
70
|
+
return trimmed;
|
|
67
71
|
}
|
|
@@ -223,6 +223,61 @@ describe("scenario 6: access control", () => {
|
|
|
223
223
|
expectErrorIncludes(error, "access_denied");
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
+
test("TenantAdmin (ohne SystemAdmin) cannot create a tier-assignment — Self-Upgrade-Schutz", async () => {
|
|
227
|
+
// Tier-Wechsel ist Plattform-/Billing-Hoheit: ein Tenant-Admin darf
|
|
228
|
+
// seinen eigenen Tier NICHT setzen (sonst Gratis-Self-Upgrade).
|
|
229
|
+
const tenantAdmin = createTestUser({
|
|
230
|
+
id: 310,
|
|
231
|
+
tenantId: testTenantId(310),
|
|
232
|
+
roles: ["TenantAdmin"],
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const error = await stack.http.writeErr(
|
|
236
|
+
TierEngineHandlers.create,
|
|
237
|
+
{ tier: "pro" },
|
|
238
|
+
tenantAdmin,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
expectErrorIncludes(error, "access_denied");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("TenantAdmin cannot update a tier-assignment", async () => {
|
|
245
|
+
const sysadmin = createTestUser({
|
|
246
|
+
id: 311,
|
|
247
|
+
tenantId: testTenantId(311),
|
|
248
|
+
roles: ["SystemAdmin"],
|
|
249
|
+
});
|
|
250
|
+
const created = await stack.http.writeOk(TierEngineHandlers.create, { tier: "free" }, sysadmin);
|
|
251
|
+
const id = (created!["data"] as Record<string, unknown>)["id"] as string;
|
|
252
|
+
|
|
253
|
+
// Selber Tenant, aber reiner TenantAdmin → darf den Tier nicht ändern.
|
|
254
|
+
const tenantAdmin = createTestUser({
|
|
255
|
+
id: 312,
|
|
256
|
+
tenantId: testTenantId(311),
|
|
257
|
+
roles: ["TenantAdmin"],
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const error = await stack.http.writeErr(
|
|
261
|
+
TierEngineHandlers.update,
|
|
262
|
+
{ id, version: 1, changes: { tier: "agency" } },
|
|
263
|
+
tenantAdmin,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
expectErrorIncludes(error, "access_denied");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("SystemAdmin (ohne TenantAdmin) CAN create a tier-assignment", async () => {
|
|
270
|
+
const sysadmin = createTestUser({
|
|
271
|
+
id: 313,
|
|
272
|
+
tenantId: testTenantId(313),
|
|
273
|
+
roles: ["SystemAdmin"],
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const result = await stack.http.writeOk(TierEngineHandlers.create, { tier: "team" }, sysadmin);
|
|
277
|
+
|
|
278
|
+
expect((result!["data"] as Record<string, unknown>)["tier"]).toBe("team");
|
|
279
|
+
});
|
|
280
|
+
|
|
226
281
|
test("query handlers carry the admin-only access rule (config-level check)", () => {
|
|
227
282
|
// Read-access is enforced by the same role-rule set on the query handler.
|
|
228
283
|
// We assert the rule is registered correctly — covers regression when
|
|
@@ -81,6 +81,12 @@ const tierAssignmentExecutor = createEventStoreExecutor(tierAssignmentTable, tie
|
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
const adminAccess = { access: { roles: ["TenantAdmin", "SystemAdmin"] } } as const;
|
|
84
|
+
// Tier-Wechsel ist Plattform-/Billing-Hoheit — ein Tenant-Admin darf den
|
|
85
|
+
// eigenen Tier NIE setzen (sonst Gratis-Self-Upgrade). Daher sind die
|
|
86
|
+
// Writes SystemAdmin-only; Reads (list, get-active-tier) bleiben
|
|
87
|
+
// TenantAdmin-sichtbar. Auto-default-Hook + Billing schreiben als System,
|
|
88
|
+
// hängen also nicht an diesem Handler-Access.
|
|
89
|
+
const writeAccess = { access: { roles: ["SystemAdmin"] } } as const;
|
|
84
90
|
|
|
85
91
|
/**
|
|
86
92
|
* Options for createTierEngineFeature. Both fields optional — wenn beide
|
|
@@ -167,8 +173,8 @@ export function createTierEngineFeature<
|
|
|
167
173
|
r.entity("tier-assignment", tierAssignmentEntity);
|
|
168
174
|
|
|
169
175
|
// Standard-CRUD via Helper.
|
|
170
|
-
r.writeHandler(defineEntityCreateHandler("tier-assignment", tierAssignmentEntity,
|
|
171
|
-
r.writeHandler(defineEntityUpdateHandler("tier-assignment", tierAssignmentEntity,
|
|
176
|
+
r.writeHandler(defineEntityCreateHandler("tier-assignment", tierAssignmentEntity, writeAccess));
|
|
177
|
+
r.writeHandler(defineEntityUpdateHandler("tier-assignment", tierAssignmentEntity, writeAccess));
|
|
172
178
|
|
|
173
179
|
// Reads.
|
|
174
180
|
r.queryHandler(defineEntityListHandler("tier-assignment", tierAssignmentEntity, adminAccess));
|