@cosmicdrift/kumiko-bundled-features 0.22.0 → 0.24.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__/invite-flow.integration.test.ts +4 -4
- package/src/auth-email-password/__tests__/seed-admin.integration.test.ts +3 -3
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -1
- package/src/auth-email-password/seeding.ts +9 -6
- package/src/compliance-profiles/seeding.ts +4 -1
- package/src/tenant/__tests__/seed-testing.integration.test.ts +1 -1
- package/src/tenant/seeding.ts +35 -15
- package/src/text-content/seeding.ts +4 -1
- package/src/text-content/table.ts +1 -1
- package/src/user/__tests__/seed-testing.integration.test.ts +5 -5
- package/src/user/seeding.ts +13 -8
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +178 -0
- package/src/user-data-rights-defaults/feature.ts +16 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +66 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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>",
|
|
@@ -138,12 +138,12 @@ beforeEach(async () => {
|
|
|
138
138
|
});
|
|
139
139
|
|
|
140
140
|
// Alice = Admin von Tenant-A
|
|
141
|
-
aliceId = await seedUser(stack.db, {
|
|
141
|
+
({ id: aliceId } = await seedUser(stack.db, {
|
|
142
142
|
email: ALICE_EMAIL,
|
|
143
143
|
displayName: "Alice",
|
|
144
144
|
passwordHash: await hashPassword("alice-pw-1234"),
|
|
145
145
|
emailVerified: true,
|
|
146
|
-
});
|
|
146
|
+
}));
|
|
147
147
|
await seedTenantMembership(stack.db, {
|
|
148
148
|
userId: aliceId,
|
|
149
149
|
tenantId: TENANT_A_ID,
|
|
@@ -151,12 +151,12 @@ beforeEach(async () => {
|
|
|
151
151
|
});
|
|
152
152
|
|
|
153
153
|
// Bob = Member von Tenant-B (für Branch 1 + 2 tests)
|
|
154
|
-
bobId = await seedUser(stack.db, {
|
|
154
|
+
({ id: bobId } = await seedUser(stack.db, {
|
|
155
155
|
email: BOB_EMAIL,
|
|
156
156
|
displayName: "Bob",
|
|
157
157
|
passwordHash: await hashPassword(BOB_PASSWORD),
|
|
158
158
|
emailVerified: true,
|
|
159
|
-
});
|
|
159
|
+
}));
|
|
160
160
|
await seedTenantMembership(stack.db, {
|
|
161
161
|
userId: bobId,
|
|
162
162
|
tenantId: TENANT_B_ID,
|
|
@@ -61,7 +61,7 @@ beforeEach(async () => {
|
|
|
61
61
|
|
|
62
62
|
describe("seedAdmin", () => {
|
|
63
63
|
test("legt Tenants, User mit gehashtem Password und Memberships an — Login-Roundtrip funktioniert", async () => {
|
|
64
|
-
const userId = await seedAdmin(stack.db, {
|
|
64
|
+
const { id: userId } = await seedAdmin(stack.db, {
|
|
65
65
|
email: "admin@example.com",
|
|
66
66
|
password: "secret-pw",
|
|
67
67
|
displayName: "Admin",
|
|
@@ -113,7 +113,7 @@ describe("seedAdmin", () => {
|
|
|
113
113
|
|
|
114
114
|
test("idempotent: zweiter Aufruf no-op (kein Crash, Stand bleibt)", async () => {
|
|
115
115
|
// Erstaufruf
|
|
116
|
-
const userId1 = await seedAdmin(stack.db, {
|
|
116
|
+
const { id: userId1 } = await seedAdmin(stack.db, {
|
|
117
117
|
email: "admin@example.com",
|
|
118
118
|
password: "pw1",
|
|
119
119
|
displayName: "Admin",
|
|
@@ -124,7 +124,7 @@ describe("seedAdmin", () => {
|
|
|
124
124
|
// Zweiter Aufruf — gleicher Email, anderes Password (würde theoretisch
|
|
125
125
|
// einen neuen Hash erzeugen und neu schreiben, der idempotent-Check
|
|
126
126
|
// greift VOR dem Insert).
|
|
127
|
-
const userId2 = await seedAdmin(stack.db, {
|
|
127
|
+
const { id: userId2 } = await seedAdmin(stack.db, {
|
|
128
128
|
email: "admin@example.com",
|
|
129
129
|
password: "pw2",
|
|
130
130
|
displayName: "Admin",
|
|
@@ -138,7 +138,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
138
138
|
// @cast-boundary db-runner — TenantDb.raw is DbRunner; seed-helpers
|
|
139
139
|
// operate on plain drizzle-API which both shapes expose identically.
|
|
140
140
|
const dbConn = ctx.db.raw as DbConnection;
|
|
141
|
-
const userId = await seedUserWithPassword(dbConn, {
|
|
141
|
+
const { id: userId } = await seedUserWithPassword(dbConn, {
|
|
142
142
|
email: invitationEmail,
|
|
143
143
|
password: event.payload.password,
|
|
144
144
|
displayName: invitationEmail.split("@")[0] ?? invitationEmail,
|
|
@@ -46,12 +46,12 @@ export type SeedUserWithPasswordOptions = {
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Seed a user mit Plain-Password (wird vor dem Insert mit argon2
|
|
49
|
-
* gehasht). Liefert userId, idempotent über email.
|
|
49
|
+
* gehasht). Liefert die userId, idempotent über email.
|
|
50
50
|
*/
|
|
51
51
|
export async function seedUserWithPassword(
|
|
52
52
|
db: DbConnection,
|
|
53
53
|
options: SeedUserWithPasswordOptions,
|
|
54
|
-
): Promise<string> {
|
|
54
|
+
): Promise<{ id: string }> {
|
|
55
55
|
const passwordHash = await hashPassword(options.password);
|
|
56
56
|
return seedUser(db, {
|
|
57
57
|
email: options.email,
|
|
@@ -114,7 +114,7 @@ export async function provisionSignupAccount(
|
|
|
114
114
|
key: options.tenantKey,
|
|
115
115
|
name: options.tenantName,
|
|
116
116
|
});
|
|
117
|
-
const userId = await seedUserWithPassword(db, {
|
|
117
|
+
const { id: userId } = await seedUserWithPassword(db, {
|
|
118
118
|
email: options.email,
|
|
119
119
|
password: options.password,
|
|
120
120
|
displayName: options.displayName,
|
|
@@ -155,14 +155,17 @@ export type SeedAdminOptions = {
|
|
|
155
155
|
* Password + N Tenants + N Memberships. Alles idempotent (Re-Run im
|
|
156
156
|
* persistent-DB-Modus läuft durch). Liefert die userId zurück.
|
|
157
157
|
*/
|
|
158
|
-
export async function seedAdmin(
|
|
158
|
+
export async function seedAdmin(
|
|
159
|
+
db: DbConnection,
|
|
160
|
+
options: SeedAdminOptions,
|
|
161
|
+
): Promise<{ id: string }> {
|
|
159
162
|
const by = options.by ?? TestUsers.systemAdmin;
|
|
160
163
|
|
|
161
164
|
for (const m of options.memberships) {
|
|
162
165
|
await seedTenant(db, { id: m.tenantId, key: m.tenantKey, name: m.tenantName, by });
|
|
163
166
|
}
|
|
164
167
|
|
|
165
|
-
const userId = await seedUserWithPassword(db, {
|
|
168
|
+
const { id: userId } = await seedUserWithPassword(db, {
|
|
166
169
|
email: options.email,
|
|
167
170
|
password: options.password,
|
|
168
171
|
displayName: options.displayName,
|
|
@@ -179,5 +182,5 @@ export async function seedAdmin(db: DbConnection, options: SeedAdminOptions): Pr
|
|
|
179
182
|
});
|
|
180
183
|
}
|
|
181
184
|
|
|
182
|
-
return userId;
|
|
185
|
+
return { id: userId };
|
|
183
186
|
}
|
|
@@ -45,7 +45,7 @@ export type SeedComplianceProfileOptions = {
|
|
|
45
45
|
export async function seedComplianceProfile(
|
|
46
46
|
db: DbConnection,
|
|
47
47
|
opts: SeedComplianceProfileOptions,
|
|
48
|
-
): Promise<{ id: string
|
|
48
|
+
): Promise<{ id: string }> {
|
|
49
49
|
// user.tenantId muss === opts.tenantId sein damit Event-Store-Stream
|
|
50
50
|
// + Projection im selben Tenant-Bucket landen (Memory:
|
|
51
51
|
// feedback_event_store_tenant_consistency).
|
|
@@ -73,6 +73,9 @@ export async function seedComplianceProfile(
|
|
|
73
73
|
if (!result.isSuccess) {
|
|
74
74
|
throw new Error(`seedComplianceProfile create failed: ${JSON.stringify(result)}`);
|
|
75
75
|
}
|
|
76
|
+
// @cast-boundary db-row: executor.create result.data ist die
|
|
77
|
+
// inserted Projection-Row (Record<string, unknown>); id ist nach
|
|
78
|
+
// INSERT garantiert (Runtime-Check direkt darunter).
|
|
76
79
|
const data = result.data as { id?: string };
|
|
77
80
|
if (data.id === undefined) {
|
|
78
81
|
throw new Error("seedComplianceProfile: executor.create did not return an id");
|
|
@@ -62,7 +62,7 @@ beforeEach(async () => {
|
|
|
62
62
|
|
|
63
63
|
describe("seedTenant", () => {
|
|
64
64
|
test("schreibt Projection-Row mit id/key/name", async () => {
|
|
65
|
-
const id = await seedTenant(stack.db, {
|
|
65
|
+
const { id } = await seedTenant(stack.db, {
|
|
66
66
|
id: TENANT_A,
|
|
67
67
|
key: "tenant-a",
|
|
68
68
|
name: "Tenant A",
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
// IS a test fixture, not a user request) while still producing the
|
|
25
25
|
// correct event + projection.
|
|
26
26
|
//
|
|
27
|
-
// Idempotent: calling twice for the same (userId, tenantId) is
|
|
28
|
-
// the second call
|
|
27
|
+
// Idempotent (add-only): calling twice for the same (userId, tenantId) is
|
|
28
|
+
// a no-op on the second call. Memberships have no update-semantic — to
|
|
29
|
+
// change roles, write a new event via the regular handler path.
|
|
29
30
|
|
|
30
31
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
31
32
|
import {
|
|
@@ -73,12 +74,15 @@ export type SeedTenantOptions = {
|
|
|
73
74
|
};
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
|
-
* Seed a tenant through the event-store executor. Idempotent
|
|
77
|
-
* a second call for the same `id` is a no-op
|
|
78
|
-
* `TenantHandlers.create`, minus the SystemAdmin-
|
|
79
|
-
* ConflictError-on-duplicate.
|
|
77
|
+
* Seed a tenant through the event-store executor. Idempotent add-only:
|
|
78
|
+
* a second call for the same `id` is a no-op (no update path). Same
|
|
79
|
+
* TX-semantics as the real `TenantHandlers.create`, minus the SystemAdmin-
|
|
80
|
+
* access-check and minus ConflictError-on-duplicate.
|
|
80
81
|
*/
|
|
81
|
-
export async function seedTenant(
|
|
82
|
+
export async function seedTenant(
|
|
83
|
+
db: DbRunner,
|
|
84
|
+
options: SeedTenantOptions,
|
|
85
|
+
): Promise<{ id: TenantId }> {
|
|
82
86
|
const by = options.by ?? TestUsers.systemAdmin;
|
|
83
87
|
// executor.create erwartet eine TenantDb (mit .insert()-API), nicht
|
|
84
88
|
// die rohe DbConnection. Auch wenn das Tenant-Aggregat selbst NICHT
|
|
@@ -88,14 +92,14 @@ export async function seedTenant(db: DbRunner, options: SeedTenantOptions): Prom
|
|
|
88
92
|
const tdb = createTenantDb(db, by.tenantId, "system");
|
|
89
93
|
|
|
90
94
|
const existing = await fetchOne(db, tenantTable, { id: options.id });
|
|
91
|
-
if (existing) return options.id;
|
|
95
|
+
if (existing) return { id: options.id };
|
|
92
96
|
|
|
93
97
|
// Idempotenz: Aggregate kann im Event-Store existieren ohne Projection-Row
|
|
94
98
|
// (Projection-Drift nach rebuild, manuellem DELETE, oder async-lag). Wenn
|
|
95
99
|
// Stream-Version > 0 → kein create() — wäre version_conflict. Caller
|
|
96
100
|
// bekommt die ID, Projection wird beim nächsten Dispatcher-Cycle aufgebaut.
|
|
97
101
|
const streamVersion = await getAggregateStreamMaxVersion(db, options.id);
|
|
98
|
-
if (streamVersion > 0) return options.id;
|
|
102
|
+
if (streamVersion > 0) return { id: options.id };
|
|
99
103
|
|
|
100
104
|
const result = await tenantExecutor.create(
|
|
101
105
|
{ id: options.id, key: options.key, name: options.name },
|
|
@@ -107,7 +111,7 @@ export async function seedTenant(db: DbRunner, options: SeedTenantOptions): Prom
|
|
|
107
111
|
`seedTenant failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
108
112
|
);
|
|
109
113
|
}
|
|
110
|
-
return options.id;
|
|
114
|
+
return { id: options.id };
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
/**
|
|
@@ -116,11 +120,13 @@ export async function seedTenant(db: DbRunner, options: SeedTenantOptions): Prom
|
|
|
116
120
|
* projection row in one transaction — identical effect to
|
|
117
121
|
* `TenantHandlers.addMember`, minus the access-check and minus the
|
|
118
122
|
* ConflictError on duplicates (duplicate calls no-op).
|
|
123
|
+
*
|
|
124
|
+
* Returns the membership-row id (existing on no-op, freshly minted on create).
|
|
119
125
|
*/
|
|
120
126
|
export async function seedTenantMembership(
|
|
121
127
|
db: DbRunner,
|
|
122
128
|
options: SeedTenantMembershipOptions,
|
|
123
|
-
): Promise<
|
|
129
|
+
): Promise<{ id: string }> {
|
|
124
130
|
const by = options.by ?? TestUsers.systemAdmin;
|
|
125
131
|
// Wrap into a system-scoped TenantDb so the insert respects the tenant-
|
|
126
132
|
// override (we write into options.tenantId, which may differ from by.tenantId).
|
|
@@ -134,10 +140,11 @@ export async function seedTenantMembership(
|
|
|
134
140
|
userId: options.userId,
|
|
135
141
|
tenantId: options.tenantId,
|
|
136
142
|
});
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
143
|
+
if (existing) {
|
|
144
|
+
// @cast-boundary db-row: membership-row id is uuid (string) per
|
|
145
|
+
// entity definition; fetchOne returns the raw projection row.
|
|
146
|
+
return { id: existing["id"] as string };
|
|
147
|
+
}
|
|
141
148
|
|
|
142
149
|
const result = await executor.create(
|
|
143
150
|
{
|
|
@@ -153,4 +160,17 @@ export async function seedTenantMembership(
|
|
|
153
160
|
`seedTenantMembership failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
154
161
|
);
|
|
155
162
|
}
|
|
163
|
+
return { id: extractMembershipId(result.data) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function extractMembershipId(data: unknown): string {
|
|
167
|
+
if (typeof data === "object" && data !== null && "id" in data) {
|
|
168
|
+
// @cast-boundary engine-bridge: executor.create returns the projection
|
|
169
|
+
// row as Record<string, unknown>; id is uuid per entity definition.
|
|
170
|
+
const id = (data as { id: unknown }).id;
|
|
171
|
+
if (typeof id === "string") return id;
|
|
172
|
+
}
|
|
173
|
+
throw new Error(
|
|
174
|
+
`seedTenantMembership: executor.create returned no string id (got ${JSON.stringify(data)})`,
|
|
175
|
+
);
|
|
156
176
|
}
|
|
@@ -36,7 +36,7 @@ export type SeedTextBlockOptions = {
|
|
|
36
36
|
export async function seedTextBlock(
|
|
37
37
|
db: DbConnection,
|
|
38
38
|
opts: SeedTextBlockOptions,
|
|
39
|
-
): Promise<{ id: string
|
|
39
|
+
): Promise<{ id: string }> {
|
|
40
40
|
// Default-user muss user.tenantId === opts.tenantId haben, sonst
|
|
41
41
|
// landet der event-store-stream im user.tenantId-bucket aber die
|
|
42
42
|
// projection-row im opts.tenantId-bucket. Spätere echte writes via
|
|
@@ -75,6 +75,9 @@ export async function seedTextBlock(
|
|
|
75
75
|
if (!result.isSuccess) {
|
|
76
76
|
throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
|
|
77
77
|
}
|
|
78
|
+
// @cast-boundary db-row: executor.create result.data ist die
|
|
79
|
+
// inserted Drizzle-Row (Record<string, unknown>), projected
|
|
80
|
+
// nach INSERT/RETURNING auf TextBlockRow. Runtime-Check unten.
|
|
78
81
|
const data = result.data as Partial<TextBlockRow>;
|
|
79
82
|
if (data.id === undefined) {
|
|
80
83
|
throw new Error("seedTextBlock: executor.create did not return an id");
|
|
@@ -38,7 +38,7 @@ export const textBlocksTable = buildEntityTable("text-block", textBlockEntity);
|
|
|
38
38
|
// createdAt, updatedAt, createdBy, updatedBy) die buildBaseColumns
|
|
39
39
|
// erzwingt.
|
|
40
40
|
export type TextBlockRow = {
|
|
41
|
-
readonly id: string
|
|
41
|
+
readonly id: string;
|
|
42
42
|
readonly version: number;
|
|
43
43
|
readonly tenantId: string;
|
|
44
44
|
readonly slug: string;
|
|
@@ -47,7 +47,7 @@ beforeEach(async () => {
|
|
|
47
47
|
|
|
48
48
|
describe("seedUser", () => {
|
|
49
49
|
test("schreibt Projection-Row mit email/displayName/passwordHash", async () => {
|
|
50
|
-
const userId = await seedUser(stack.db, {
|
|
50
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
51
51
|
email: "alice@example.com",
|
|
52
52
|
displayName: "Alice",
|
|
53
53
|
passwordHash: "$argon2id$test-hash",
|
|
@@ -62,7 +62,7 @@ describe("seedUser", () => {
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
test("emittiert user.created-Event auf den Aggregate-Stream", async () => {
|
|
65
|
-
const userId = await seedUser(stack.db, {
|
|
65
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
66
66
|
email: "bob@example.com",
|
|
67
67
|
displayName: "Bob",
|
|
68
68
|
});
|
|
@@ -84,7 +84,7 @@ describe("seedUser", () => {
|
|
|
84
84
|
email: "carol@example.com",
|
|
85
85
|
displayName: "Carol Updated",
|
|
86
86
|
});
|
|
87
|
-
expect(second).toBe(first);
|
|
87
|
+
expect(second.id).toBe(first.id);
|
|
88
88
|
|
|
89
89
|
const rows = await selectMany(stack.db, userTable, { email: "carol@example.com" });
|
|
90
90
|
expect(rows).toHaveLength(1);
|
|
@@ -96,7 +96,7 @@ describe("seedUser", () => {
|
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
test("passwordHash optional — User ohne Hash anlegbar (z.B. SSO-Federation)", async () => {
|
|
99
|
-
const userId = await seedUser(stack.db, {
|
|
99
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
100
100
|
email: "dave@example.com",
|
|
101
101
|
displayName: "Dave",
|
|
102
102
|
});
|
|
@@ -105,7 +105,7 @@ describe("seedUser", () => {
|
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
test("default `by` ist TestUsers.systemAdmin (für audit-trail)", async () => {
|
|
108
|
-
const userId = await seedUser(stack.db, {
|
|
108
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
109
109
|
email: "eve@example.com",
|
|
110
110
|
displayName: "Eve",
|
|
111
111
|
});
|
package/src/user/seeding.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Testing-Helper fürs user-Feature. `seedUser` legt einen User direkt
|
|
2
2
|
// über den Event-Store-Executor an — gleicher Pfad wie der echte
|
|
3
3
|
// `UserHandlers.create`, aber ohne Access-Check und ohne ConflictError
|
|
4
|
-
// bei Duplikaten.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// bei Duplikaten. Idempotent add-only über die `email`-Spalte: ein
|
|
5
|
+
// existierender User → return ohne Event. Kein update-Pfad — Profilfelder
|
|
6
|
+
// ändern läuft über den regulären Handler.
|
|
7
7
|
//
|
|
8
8
|
// Warum nicht direkt `db.insert(userTable)`: das würde den Event-Store
|
|
9
9
|
// umgehen, also kein `user.created`-Event und keine MSP-Konsumenten
|
|
@@ -46,10 +46,13 @@ export type SeedUserOptions = {
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Seed a user. Returns the userId (existing oder neu angelegt).
|
|
49
|
-
* Idempotent über die `email`-Spalte: wenn ein User mit dieser
|
|
50
|
-
* existiert, kommt seine ID zurück ohne neuen Insert.
|
|
49
|
+
* Idempotent add-only über die `email`-Spalte: wenn ein User mit dieser
|
|
50
|
+
* Email existiert, kommt seine ID zurück ohne neuen Insert.
|
|
51
51
|
*/
|
|
52
|
-
export async function seedUser(
|
|
52
|
+
export async function seedUser(
|
|
53
|
+
db: DbConnection,
|
|
54
|
+
options: SeedUserOptions,
|
|
55
|
+
): Promise<{ id: string }> {
|
|
53
56
|
const by = options.by ?? TestUsers.systemAdmin;
|
|
54
57
|
// executor.create erwartet eine TenantDb (mit .insert()-API). User
|
|
55
58
|
// ist zwar tenant-agnostic (kein tenant_id-Spalte), aber das runtime-
|
|
@@ -57,7 +60,9 @@ export async function seedUser(db: DbConnection, options: SeedUserOptions): Prom
|
|
|
57
60
|
const tdb = createTenantDb(db, by.tenantId, "system");
|
|
58
61
|
|
|
59
62
|
const existing = await fetchOne(db, userTable, { email: options.email });
|
|
60
|
-
|
|
63
|
+
// @cast-boundary db-row: users.id ist uuid-Spalte (string), fetchOne
|
|
64
|
+
// liefert die Projection-Row als Record<string, unknown>.
|
|
65
|
+
if (existing) return { id: existing["id"] as string };
|
|
61
66
|
|
|
62
67
|
const result = await userExecutor.create(
|
|
63
68
|
{
|
|
@@ -76,7 +81,7 @@ export async function seedUser(db: DbConnection, options: SeedUserOptions): Prom
|
|
|
76
81
|
`seedUser failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
77
82
|
);
|
|
78
83
|
}
|
|
79
|
-
return extractId(result.data, "seedUser");
|
|
84
|
+
return { id: extractId(result.data, "seedUser") };
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
// Extrahiert die `id`-Spalte aus dem executor.create-Result. Der
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Forget-Hook binary-Cleanup Integration-Test.
|
|
2
|
+
//
|
|
3
|
+
// Beweist, dass der `fileRef`-Forget-Hook bei strategy="delete" die
|
|
4
|
+
// Storage-Binaries via `storageProvider.delete()` entfernt, BEVOR die
|
|
5
|
+
// row hard-gelöscht wird — ohne provider leakt sonst jede gelöschte
|
|
6
|
+
// Datei ihre Bytes dauerhaft auf Disk (Issue gefunden im Review zu #177).
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import {
|
|
12
|
+
createInMemoryFileProvider,
|
|
13
|
+
fileRefsTable,
|
|
14
|
+
type InMemoryFileProvider,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
16
|
+
import {
|
|
17
|
+
setupTestStack,
|
|
18
|
+
type TestStack,
|
|
19
|
+
unsafeCreateEntityTable,
|
|
20
|
+
unsafePushTables,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
23
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
24
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
25
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
26
|
+
import { createFilesFeature } from "../../files";
|
|
27
|
+
import { createSessionsFeature } from "../../sessions";
|
|
28
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
29
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
30
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
31
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
32
|
+
|
|
33
|
+
let stack: TestStack;
|
|
34
|
+
let db: DbConnection;
|
|
35
|
+
let provider: InMemoryFileProvider;
|
|
36
|
+
|
|
37
|
+
const TENANT = "00000000-0000-4000-8000-00000000000c";
|
|
38
|
+
const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
39
|
+
|
|
40
|
+
function uuid(suffix: number): string {
|
|
41
|
+
return `bbbbbbbb-bbbb-4bbb-8bbb-${suffix.toString(16).padStart(12, "0")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
45
|
+
const NOW = (): Instant => getTemporal().Now.instant();
|
|
46
|
+
const pastInstant = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
provider = createInMemoryFileProvider();
|
|
50
|
+
stack = await setupTestStack({
|
|
51
|
+
features: [
|
|
52
|
+
createUserFeature(),
|
|
53
|
+
createFilesFeature(),
|
|
54
|
+
createDataRetentionFeature(),
|
|
55
|
+
createComplianceProfilesFeature(),
|
|
56
|
+
createSessionsFeature(),
|
|
57
|
+
createUserDataRightsFeature(),
|
|
58
|
+
createUserDataRightsDefaultsFeature({ storageProvider: provider }),
|
|
59
|
+
],
|
|
60
|
+
files: { storageProvider: provider },
|
|
61
|
+
});
|
|
62
|
+
db = stack.db;
|
|
63
|
+
|
|
64
|
+
await unsafeCreateEntityTable(db, userEntity);
|
|
65
|
+
await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
|
|
66
|
+
await unsafePushTables(db, { fileRefsTable });
|
|
67
|
+
await asRawClient(db).unsafe(`
|
|
68
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
69
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
70
|
+
tenant_id UUID NOT NULL,
|
|
71
|
+
user_id TEXT NOT NULL,
|
|
72
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
74
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
75
|
+
inserted_by_id TEXT,
|
|
76
|
+
modified_by_id TEXT,
|
|
77
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
78
|
+
deleted_at TIMESTAMPTZ,
|
|
79
|
+
deleted_by_id TEXT,
|
|
80
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
81
|
+
UNIQUE(user_id, tenant_id)
|
|
82
|
+
)
|
|
83
|
+
`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterAll(async () => {
|
|
87
|
+
await stack.cleanup();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
provider.clear();
|
|
92
|
+
await resetTestTables(db, [userTable, "read_tenant_memberships", fileRefsTable]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
async function seedForgetUser(id: string): Promise<void> {
|
|
96
|
+
await insertOne(db, userTable, {
|
|
97
|
+
id,
|
|
98
|
+
tenantId: TENANT_SYSTEM,
|
|
99
|
+
email: `user-${id}@example.com`,
|
|
100
|
+
passwordHash: "hashed",
|
|
101
|
+
displayName: `User ${id}`,
|
|
102
|
+
locale: "de",
|
|
103
|
+
emailVerified: true,
|
|
104
|
+
roles: '["Member"]',
|
|
105
|
+
status: USER_STATUS.DeletionRequested,
|
|
106
|
+
gracePeriodEnd: pastInstant(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function seedMembership(userId: string, tenantId: string): Promise<void> {
|
|
111
|
+
await asRawClient(db).unsafe(
|
|
112
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
|
|
113
|
+
VALUES ($1, $2, '["Member"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
|
|
114
|
+
[tenantId, userId],
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function seedFile(id: string, tenantId: string, insertedById: string): Promise<string> {
|
|
119
|
+
const storageKey = `storage/${id}`;
|
|
120
|
+
await provider.write(storageKey, new Uint8Array([1, 2, 3, 4]), "application/pdf");
|
|
121
|
+
await asRawClient(db).unsafe(
|
|
122
|
+
`INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
|
|
123
|
+
VALUES ($1, $2, $3, $4, 'application/pdf', 4, $5) ON CONFLICT (id) DO NOTHING`,
|
|
124
|
+
[id, tenantId, storageKey, `${id}.pdf`, insertedById],
|
|
125
|
+
);
|
|
126
|
+
return storageKey;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe("forget-binary-cleanup :: storage.delete fires before row hard-delete", () => {
|
|
130
|
+
test("Forget deletes the binary from the storage provider", async () => {
|
|
131
|
+
const userId = uuid(1);
|
|
132
|
+
await seedForgetUser(userId);
|
|
133
|
+
await seedMembership(userId, TENANT);
|
|
134
|
+
const key = await seedFile(uuid(101), TENANT, userId);
|
|
135
|
+
expect(await provider.exists(key)).toBe(true);
|
|
136
|
+
|
|
137
|
+
const result = await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
138
|
+
|
|
139
|
+
expect(result.processedUserIds).toContain(userId);
|
|
140
|
+
expect(await provider.exists(key)).toBe(false);
|
|
141
|
+
expect(provider.keys()).not.toContain(key);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("Multiple files from the same user — all binaries cleaned up", async () => {
|
|
145
|
+
const userId = uuid(2);
|
|
146
|
+
await seedForgetUser(userId);
|
|
147
|
+
await seedMembership(userId, TENANT);
|
|
148
|
+
const keys = await Promise.all([
|
|
149
|
+
seedFile(uuid(201), TENANT, userId),
|
|
150
|
+
seedFile(uuid(202), TENANT, userId),
|
|
151
|
+
seedFile(uuid(203), TENANT, userId),
|
|
152
|
+
]);
|
|
153
|
+
expect(provider.keys()).toHaveLength(3);
|
|
154
|
+
|
|
155
|
+
await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
156
|
+
|
|
157
|
+
for (const key of keys) {
|
|
158
|
+
expect(await provider.exists(key)).toBe(false);
|
|
159
|
+
}
|
|
160
|
+
expect(provider.keys()).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("Other tenants' files stay untouched", async () => {
|
|
164
|
+
const userId = uuid(3);
|
|
165
|
+
const otherTenant = "00000000-0000-4000-8000-00000000000d";
|
|
166
|
+
await seedForgetUser(userId);
|
|
167
|
+
await seedMembership(userId, TENANT);
|
|
168
|
+
const myKey = await seedFile(uuid(301), TENANT, userId);
|
|
169
|
+
const otherKey = await seedFile(uuid(302), otherTenant, "another-user");
|
|
170
|
+
// The other-tenant file is owned by a different user; the forget run for
|
|
171
|
+
// userId must NOT touch it.
|
|
172
|
+
|
|
173
|
+
await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
174
|
+
|
|
175
|
+
expect(await provider.exists(myKey)).toBe(false);
|
|
176
|
+
expect(await provider.exists(otherKey)).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -3,9 +3,20 @@ import {
|
|
|
3
3
|
EXT_USER_DATA,
|
|
4
4
|
type FeatureDefinition,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
-
import {
|
|
6
|
+
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
7
|
+
import { createFileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
|
|
7
8
|
import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
|
|
8
9
|
|
|
10
|
+
export interface UserDataRightsDefaultsOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Wired into the fileRef delete-hook: on strategy="delete" the hook
|
|
13
|
+
* calls `storageProvider.delete(key)` per row before hard-deleting
|
|
14
|
+
* the row. Without it, file binaries leak on forget (Art. 17) — the
|
|
15
|
+
* hook logs a one-shot warning so misconfiguration stays visible.
|
|
16
|
+
*/
|
|
17
|
+
readonly storageProvider?: FileStorageProvider;
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
// user-data-rights-defaults — Default-Hooks für die Core-Entities
|
|
10
21
|
// `user` (S2.H1) und `fileRef` (S2.H2).
|
|
11
22
|
//
|
|
@@ -23,7 +34,10 @@ import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
|
|
|
23
34
|
// Pattern matched file-foundation + file-provider-s3 (separate Plugin-
|
|
24
35
|
// Feature), nicht user/files schreiben ihre eigenen Hooks selbst weil
|
|
25
36
|
// das circular-requires waere.
|
|
26
|
-
export function createUserDataRightsDefaultsFeature(
|
|
37
|
+
export function createUserDataRightsDefaultsFeature(
|
|
38
|
+
options: UserDataRightsDefaultsOptions = {},
|
|
39
|
+
): FeatureDefinition {
|
|
40
|
+
const fileRefDeleteHook = createFileRefDeleteHook(options.storageProvider);
|
|
27
41
|
return defineFeature("user-data-rights-defaults", (r) => {
|
|
28
42
|
r.requires("user", "files", "user-data-rights");
|
|
29
43
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { deleteMany, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
3
|
+
import { type FileStorageProvider, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
4
4
|
|
|
5
5
|
// userData-Hook fuer fileRef-entity (S2.H2).
|
|
6
6
|
//
|
|
@@ -9,10 +9,9 @@ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
|
9
9
|
// NICHT direkt — sie werden via signed-Download-URLs separat ins ZIP
|
|
10
10
|
// gepackt (S2.U3 Export-Job-Pipeline orchestriert das).
|
|
11
11
|
//
|
|
12
|
-
// Delete-Hook entfernt FileRef-Zeile
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// "delete": Row hard-delete + storageProvider.delete() pro File
|
|
12
|
+
// Delete-Hook entfernt FileRef-Zeile via factory
|
|
13
|
+
// `createFileRefDeleteHook(storageProvider)`:
|
|
14
|
+
// "delete": storageProvider.delete() pro File (best-effort) + Row hard-delete
|
|
16
15
|
// "anonymize": insertedById=null, Row + binary bleiben (FK-Refs
|
|
17
16
|
// koennen weiter zeigen; Personenbezug raus)
|
|
18
17
|
//
|
|
@@ -22,12 +21,17 @@ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
|
22
21
|
// idempotent, KEIN globaler Rollback — wenn ein File-Delete failt,
|
|
23
22
|
// bleibt der User-Row trotzdem anonymisiert.
|
|
24
23
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
24
|
+
// `storageProvider` ist optional. App-Author wired es beim
|
|
25
|
+
// Feature-Mount rein (`createUserDataRightsDefaultsFeature({
|
|
26
|
+
// storageProvider })`). Ohne Provider macht der Hook row-only-delete,
|
|
27
|
+
// die Bytes leaken — der Caller bekommt EINEN Warn beim ersten Lauf
|
|
28
|
+
// pro Process, damit die Konfiguration sichtbar fehlerhaft ist.
|
|
29
|
+
//
|
|
30
|
+
// Caveat: hard-delete via deleteMany emittiert KEIN fileRef.deleted —
|
|
31
|
+
// die storage-tracking-MSP dekrementiert nicht. Wenn die zu loeschenden
|
|
32
|
+
// Files vorher nicht soft-deleted waren, bleibt `tenant_storage_usage`
|
|
33
|
+
// inflated. Forget-Flows sind selten (per-User-Art.-17) und damit
|
|
34
|
+
// bounded; ein executor.purge-API folgt mit dem trashed-files-GC.
|
|
31
35
|
|
|
32
36
|
export const fileRefExportHook: UserDataExportHook = async (ctx) => {
|
|
33
37
|
// isDeleted:false — soft-deleted (trashed) Files gehören nicht ins
|
|
@@ -76,22 +80,54 @@ export const fileRefExportHook: UserDataExportHook = async (ctx) => {
|
|
|
76
80
|
};
|
|
77
81
|
};
|
|
78
82
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
83
|
+
let missingStorageWarned = false;
|
|
84
|
+
|
|
85
|
+
export function createFileRefDeleteHook(
|
|
86
|
+
storageProvider: FileStorageProvider | undefined,
|
|
87
|
+
): UserDataDeleteHook {
|
|
88
|
+
return async (ctx, strategy) => {
|
|
89
|
+
if (strategy === "delete") {
|
|
90
|
+
if (storageProvider) {
|
|
91
|
+
const rows = await selectMany(ctx.db, fileRefsTable, {
|
|
92
|
+
tenantId: ctx.tenantId,
|
|
93
|
+
insertedById: ctx.userId,
|
|
94
|
+
});
|
|
95
|
+
for (const row of rows) {
|
|
96
|
+
const key = (row as Record<string, unknown>)["storageKey"]; // @cast-boundary db-row
|
|
97
|
+
if (typeof key !== "string" || key.length === 0) continue;
|
|
98
|
+
try {
|
|
99
|
+
await storageProvider.delete(key);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
|
|
102
|
+
console.warn(
|
|
103
|
+
`[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} else if (!missingStorageWarned) {
|
|
108
|
+
missingStorageWarned = true;
|
|
109
|
+
// biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow
|
|
110
|
+
console.warn(
|
|
111
|
+
"[user-data-rights-defaults:fileRef] no storageProvider configured — file binaries are NOT deleted on forget. Pass createUserDataRightsDefaultsFeature({ storageProvider }) to fix.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
await deleteMany(ctx.db, fileRefsTable, { tenantId: ctx.tenantId, insertedById: ctx.userId });
|
|
115
|
+
} else {
|
|
116
|
+
// anonymize: insertedById=null, FileRef + binary bleiben.
|
|
117
|
+
// Use-case: shared chat-Attachment in einem Multi-User-Channel —
|
|
118
|
+
// Author-Identifikation raus, Datei bleibt fuer andere User
|
|
119
|
+
// sichtbar.
|
|
120
|
+
await updateMany(
|
|
121
|
+
ctx.db,
|
|
122
|
+
fileRefsTable,
|
|
123
|
+
{ insertedById: null },
|
|
124
|
+
{ tenantId: ctx.tenantId, insertedById: ctx.userId },
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Legacy export: storage-less hook for callers that haven't migrated.
|
|
131
|
+
// Binaries are NOT cleaned up — disk leak. Migrate to
|
|
132
|
+
// createUserDataRightsDefaultsFeature({ storageProvider }).
|
|
133
|
+
export const fileRefDeleteHook: UserDataDeleteHook = createFileRefDeleteHook(undefined);
|