@cosmicdrift/kumiko-bundled-features 0.24.0 → 0.25.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/__tests__/env-schemas.test.ts +53 -11
- package/src/auth-email-password/__tests__/auth.integration.test.ts +37 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +32 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -0
- package/src/auth-email-password/handlers/change-password.write.ts +12 -2
- package/src/auth-email-password/handlers/confirm-token-flow.ts +17 -2
- package/src/compliance-profiles/__tests__/parse-override.test.ts +53 -0
- package/src/compliance-profiles/_internal/parse-override.ts +8 -7
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
- package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +178 -0
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
- package/src/custom-fields/__tests__/drift.test.ts +43 -0
- package/src/custom-fields/__tests__/field-access.integration.test.ts +59 -0
- package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
- package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
- package/src/custom-fields/__tests__/value-schema.test.ts +54 -0
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
- package/src/custom-fields/constants.ts +8 -7
- package/src/custom-fields/db/queries/projection.ts +19 -7
- package/src/custom-fields/db/queries/retention.ts +20 -6
- package/src/custom-fields/executor.ts +10 -0
- package/src/custom-fields/feature.ts +32 -39
- package/src/custom-fields/handlers/clear-custom-field.write.ts +8 -1
- package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
- package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
- package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
- package/src/custom-fields/handlers/set-custom-field.write.ts +8 -1
- package/src/custom-fields/lib/field-access.ts +9 -4
- package/src/custom-fields/lib/field-definition-row.ts +33 -0
- package/src/custom-fields/lib/value-schema.ts +14 -2
- package/src/custom-fields/run-retention.ts +6 -5
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
- package/src/custom-fields/web/client-plugin.tsx +2 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
- package/src/custom-fields/web/i18n.ts +30 -0
- package/src/custom-fields/wire-for-entity.ts +9 -2
- package/src/custom-fields/wire-user-data-rights.ts +9 -0
- package/src/feature-toggles/handlers/set.write.ts +13 -8
- package/src/secrets/feature.ts +4 -11
- package/src/subscription-stripe/feature.ts +2 -2
- package/src/template-resolver/handlers/list.query.ts +12 -10
- package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
- package/src/tenant/seeding.ts +3 -3
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +11 -11
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
- package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
- package/src/user-data-rights/run-forget-cleanup.ts +77 -36
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Default translation bundle for the custom-fields UI. customFieldsClient()
|
|
3
|
+
// hangs it into the LocaleProvider as a fallback bundle — apps override
|
|
4
|
+
// individual keys via `customFieldsClient({ translations: { de: { ... } } })`.
|
|
5
|
+
//
|
|
6
|
+
// Keys follow `custom-fields.<area>.<slug>`. `custom-fields.errors.*` mirror
|
|
7
|
+
// the i18nKeys the server-side handlers emit (e.g. `custom-fields:save-failed`).
|
|
8
|
+
|
|
9
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
10
|
+
|
|
11
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
12
|
+
de: {
|
|
13
|
+
"custom-fields.form.createMode": "Speichere zuerst den Eintrag, um Custom-Felder zu setzen.",
|
|
14
|
+
"custom-fields.form.loading": "Lädt…",
|
|
15
|
+
"custom-fields.form.empty": 'Keine Custom-Felder für "{entityName}" definiert.',
|
|
16
|
+
"custom-fields.form.save": "Custom-Felder speichern",
|
|
17
|
+
"custom-fields.form.saving": "Speichert…",
|
|
18
|
+
"custom-fields.errors.loadFailed": "Custom-Felder konnten nicht geladen werden.",
|
|
19
|
+
"custom-fields.errors.saveFailed": "Speichern fehlgeschlagen.",
|
|
20
|
+
},
|
|
21
|
+
en: {
|
|
22
|
+
"custom-fields.form.createMode": "Save the entity first to add custom field values.",
|
|
23
|
+
"custom-fields.form.loading": "Loading…",
|
|
24
|
+
"custom-fields.form.empty": 'No custom fields defined for "{entityName}".',
|
|
25
|
+
"custom-fields.form.save": "Save custom fields",
|
|
26
|
+
"custom-fields.form.saving": "Saving…",
|
|
27
|
+
"custom-fields.errors.loadFailed": "Could not load custom fields.",
|
|
28
|
+
"custom-fields.errors.saveFailed": "Save failed.",
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -114,6 +114,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
|
114
114
|
payload.fieldKey,
|
|
115
115
|
payload.value,
|
|
116
116
|
event.aggregateId,
|
|
117
|
+
event.tenantId,
|
|
117
118
|
);
|
|
118
119
|
},
|
|
119
120
|
[clearedEventType]: async (event, tx) => {
|
|
@@ -124,7 +125,13 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
|
124
125
|
|
|
125
126
|
// jsonb minus operator (`-`) entfernt key aus jsonb-object.
|
|
126
127
|
const tableName = getTableName(entityTable);
|
|
127
|
-
await clearCustomFieldKey(
|
|
128
|
+
await clearCustomFieldKey(
|
|
129
|
+
tx,
|
|
130
|
+
tableName,
|
|
131
|
+
payload.fieldKey,
|
|
132
|
+
event.aggregateId,
|
|
133
|
+
event.tenantId,
|
|
134
|
+
);
|
|
128
135
|
},
|
|
129
136
|
[fieldDefDeletedType]: async (event, tx) => {
|
|
130
137
|
// fieldDefinition.deleted fires nur einmal pro fieldDef-delete
|
|
@@ -164,8 +171,8 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
|
164
171
|
const customFields = row["customFields"];
|
|
165
172
|
if (customFields && typeof customFields === "object" && !Array.isArray(customFields)) {
|
|
166
173
|
return {
|
|
167
|
-
...row,
|
|
168
174
|
...(customFields as Record<string, unknown>), // @cast-boundary db-row jsonb runtime-untyped
|
|
175
|
+
...row, // base fields win: a custom fieldKey named `id`/`name` must not shadow the real column
|
|
169
176
|
};
|
|
170
177
|
}
|
|
171
178
|
return row;
|
|
@@ -40,6 +40,14 @@ function asCustomFieldsHostRow(value: unknown): CustomFieldsHostRow | null {
|
|
|
40
40
|
return { id: value.id, customFields: Object.fromEntries(Object.entries(cf)) };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// The anonymize strip filters rows by `WHERE userIdColumn = userId`. A host
|
|
44
|
+
// anonymize hook on the SAME entity that nulls that column (e.g. inserted_by_id
|
|
45
|
+
// = NULL) would, if it ran first, leave the strip matching 0 rows → sensitive
|
|
46
|
+
// jsonb PII silently retained (DSGVO Art. 17 violation). A negative order makes
|
|
47
|
+
// runForgetCleanup run this strip before any default-order (0) owner-nulling
|
|
48
|
+
// hook, independent of feature registration order.
|
|
49
|
+
const ORDER_REDACT_BEFORE_OWNER_MUTATION = -100;
|
|
50
|
+
|
|
43
51
|
export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<string>>(
|
|
44
52
|
r: TReg,
|
|
45
53
|
opts: WireCustomFieldsUserDataRightsOptions,
|
|
@@ -88,6 +96,7 @@ export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<
|
|
|
88
96
|
r.useExtension(EXT_USER_DATA, opts.entityName, {
|
|
89
97
|
export: exportHook,
|
|
90
98
|
delete: deleteHook,
|
|
99
|
+
order: ORDER_REDACT_BEFORE_OWNER_MUTATION,
|
|
91
100
|
});
|
|
92
101
|
}
|
|
93
102
|
|
|
@@ -42,6 +42,17 @@ export function createSetWriteHandler(getRuntime: (() => GlobalFeatureToggleRunt
|
|
|
42
42
|
handler: async (event, ctx) => {
|
|
43
43
|
const { featureName, enabled } = event.payload;
|
|
44
44
|
|
|
45
|
+
// Guard 0: fail fast on a misconfigured app BEFORE any DB write or event
|
|
46
|
+
// append — otherwise the row + toggle-set event commit and the operator
|
|
47
|
+
// only sees the error, leaving the in-memory snapshot stale until reboot.
|
|
48
|
+
if (!getRuntime) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
"[feature-toggles] set-handler called but createFeatureTogglesFeature " +
|
|
51
|
+
"was wired up without `getRuntime`. Wire the accessor in your app-config " +
|
|
52
|
+
"(production: `() => runtime` after buildServer; tests: createLateBoundHolder.get).",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
// Guard 1: featureName must be a registered feature. Otherwise we'd
|
|
46
57
|
// pile up orphan rows from typos that the gate would silently apply
|
|
47
58
|
// (if someone ever added a feature with that name later).
|
|
@@ -153,14 +164,8 @@ export function createSetWriteHandler(getRuntime: (() => GlobalFeatureToggleRunt
|
|
|
153
164
|
// for a dispatcher tick. Other instances learn the change through
|
|
154
165
|
// the `toggle-cache-sync` MSP (see feature-toggles-feature.ts). Both
|
|
155
166
|
// paths are idempotent — Map.set is last-write-wins and the DB is
|
|
156
|
-
// the source of truth after boot-time initialize().
|
|
157
|
-
|
|
158
|
-
throw new Error(
|
|
159
|
-
"[feature-toggles] set-handler called but createFeatureTogglesFeature " +
|
|
160
|
-
"was wired up without `getRuntime`. Wire the accessor in your app-config " +
|
|
161
|
-
"(production: `() => runtime` after buildServer; tests: createLateBoundHolder.get).",
|
|
162
|
-
);
|
|
163
|
-
}
|
|
167
|
+
// the source of truth after boot-time initialize(). getRuntime presence
|
|
168
|
+
// is enforced at Guard 0, so it is non-undefined here.
|
|
164
169
|
getRuntime().apply(featureName, enabled);
|
|
165
170
|
|
|
166
171
|
return {
|
package/src/secrets/feature.ts
CHANGED
|
@@ -23,21 +23,14 @@ import { tenantSecretEntity } from "./table";
|
|
|
23
23
|
export const secretsEnvSchema = z.object({
|
|
24
24
|
KUMIKO_SECRETS_MASTER_KEY_V1: z
|
|
25
25
|
.string()
|
|
26
|
-
.refine(
|
|
27
|
-
(
|
|
28
|
-
|
|
29
|
-
return Buffer.from(v, "base64").length === 32;
|
|
30
|
-
} catch {
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
},
|
|
34
|
-
{ message: "must be base64-encoded 32 bytes (AES-256 KEK)" },
|
|
35
|
-
)
|
|
26
|
+
.refine((v) => Buffer.from(v, "base64").length === 32, {
|
|
27
|
+
message: "must be base64-encoded 32 bytes (AES-256 KEK)",
|
|
28
|
+
})
|
|
36
29
|
.describe("AES-256 master-key (KEK) for tenant-secrets encryption.")
|
|
37
30
|
.meta({ kumiko: { pulumi: { generator: "openssl rand -base64 32", secret: true } } }),
|
|
38
31
|
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: z
|
|
39
32
|
.string()
|
|
40
|
-
.regex(
|
|
33
|
+
.regex(/^[1-9]\d*$/, "must be a positive integer (V<n> selector)")
|
|
41
34
|
.default("1")
|
|
42
35
|
.describe(
|
|
43
36
|
"Pins the active KEK version. Default '1'. Bump after writing a higher KUMIKO_SECRETS_MASTER_KEY_V<n>.",
|
|
@@ -60,12 +60,12 @@ import { verifyAndParseStripeWebhook } from "./verify-webhook";
|
|
|
60
60
|
export const subscriptionStripeEnvSchema = z.object({
|
|
61
61
|
STRIPE_WEBHOOK_SECRET: z
|
|
62
62
|
.string()
|
|
63
|
-
.
|
|
63
|
+
.regex(/^whsec_/, "STRIPE_WEBHOOK_SECRET must start with 'whsec_'")
|
|
64
64
|
.describe("Stripe webhook-signing secret (`whsec_...` from the Stripe dashboard).")
|
|
65
65
|
.meta({ kumiko: { pulumi: { secret: true } } }),
|
|
66
66
|
STRIPE_API_KEY: z
|
|
67
67
|
.string()
|
|
68
|
-
.
|
|
68
|
+
.regex(/^sk_(test|live)_/, "STRIPE_API_KEY must start with 'sk_test_' or 'sk_live_'")
|
|
69
69
|
.describe("Stripe API key (`sk_live_...` / `sk_test_...`).")
|
|
70
70
|
.meta({ kumiko: { pulumi: { secret: true } } }),
|
|
71
71
|
});
|
|
@@ -20,15 +20,11 @@ export const listQuery = defineQueryHandler({
|
|
|
20
20
|
const isSystemAdmin = query.user.roles.includes("SystemAdmin");
|
|
21
21
|
const where: Record<string, unknown> = {};
|
|
22
22
|
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
where["tenantId"] = query.user.tenantId;
|
|
29
|
-
}
|
|
30
|
-
} else if (!query.payload.includeSystem) {
|
|
31
|
-
// SystemAdmin mit includeSystem=false → nur eigener Tenant
|
|
23
|
+
// TenantDb scopes non-SystemAdmin reads to [own tenant, SYSTEM reference] and
|
|
24
|
+
// refuses a caller-narrowed where.tenantId (enforced isolation). SystemAdmin
|
|
25
|
+
// uses a system-scoped db that sees every tenant, so narrow to own explicitly
|
|
26
|
+
// when they don't want the cross-tenant view.
|
|
27
|
+
if (isSystemAdmin && !query.payload.includeSystem) {
|
|
32
28
|
where["tenantId"] = query.user.tenantId;
|
|
33
29
|
}
|
|
34
30
|
|
|
@@ -48,7 +44,13 @@ export const listQuery = defineQueryHandler({
|
|
|
48
44
|
limit: 500,
|
|
49
45
|
})) as TemplateResourceRow[];
|
|
50
46
|
|
|
51
|
-
|
|
47
|
+
// TenantDb always surfaces SYSTEM reference rows alongside the tenant's own;
|
|
48
|
+
// includeSystem=false drops them here since they can't be excluded at the DB.
|
|
49
|
+
const visible = query.payload.includeSystem
|
|
50
|
+
? rows
|
|
51
|
+
: rows.filter((row) => row.tenantId !== SYSTEM_TENANT_ID);
|
|
52
|
+
|
|
53
|
+
return visible.map((row) => ({
|
|
52
54
|
id: String(row.id),
|
|
53
55
|
tenantId: row.tenantId,
|
|
54
56
|
slug: row.slug,
|
|
@@ -173,6 +173,32 @@ describe("seedTenantMembership", () => {
|
|
|
173
173
|
expect(events.filter((e) => e.type === "tenant-membership.created")).toHaveLength(1);
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
test("returns the membership-row id — identical across create + no-op re-seed", async () => {
|
|
177
|
+
// Both return paths (create via extractMembershipId, no-op via fetched row)
|
|
178
|
+
// must yield the same valid uuid string that the projection actually holds.
|
|
179
|
+
// Previously the return was never asserted; a no-op returning the wrong /
|
|
180
|
+
// undefined id would have gone unnoticed.
|
|
181
|
+
const created = await seedTenantMembership(stack.db, {
|
|
182
|
+
userId: ALICE_ID,
|
|
183
|
+
tenantId: TENANT_A,
|
|
184
|
+
roles: ["User"],
|
|
185
|
+
});
|
|
186
|
+
const reSeeded = await seedTenantMembership(stack.db, {
|
|
187
|
+
userId: ALICE_ID,
|
|
188
|
+
tenantId: TENANT_A,
|
|
189
|
+
roles: ["User"],
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(created.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
|
193
|
+
expect(reSeeded.id).toBe(created.id);
|
|
194
|
+
|
|
195
|
+
const [row] = await selectMany(stack.db, tenantMembershipsTable, {
|
|
196
|
+
userId: ALICE_ID,
|
|
197
|
+
tenantId: TENANT_A,
|
|
198
|
+
});
|
|
199
|
+
expect(row?.["id"]).toBe(created.id);
|
|
200
|
+
});
|
|
201
|
+
|
|
176
202
|
test("records the `by` user as insertedById on the projection", async () => {
|
|
177
203
|
// Audit-queries that join events → users need a stable actor. Default
|
|
178
204
|
// `by` is TestUsers.systemAdmin; override to a custom test user and
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -141,9 +141,9 @@ export async function seedTenantMembership(
|
|
|
141
141
|
tenantId: options.tenantId,
|
|
142
142
|
});
|
|
143
143
|
if (existing) {
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
return { id: existing
|
|
144
|
+
// Same validation as the create-path — a missing/non-string id throws
|
|
145
|
+
// instead of silently returning `undefined as string`.
|
|
146
|
+
return { id: extractMembershipId(existing) };
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
const result = await executor.create(
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
16
16
|
import { asRawClient, deleteMany, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
17
|
+
import { defineUnmanagedTable } from "@cosmicdrift/kumiko-framework/db";
|
|
17
18
|
import {
|
|
18
19
|
defineFeature,
|
|
19
20
|
EXT_USER_DATA,
|
|
@@ -60,17 +61,16 @@ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
|
60
61
|
const NOW = (): Instant => getTemporal().Now.instant();
|
|
61
62
|
const PAST = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
};
|
|
64
|
+
// Synthetic third-party "note" table — unmanaged (no entity-system base
|
|
65
|
+
// columns). deleteMany only filters on tenant_id + author_id, so those are the
|
|
66
|
+
// only columns the query layer needs to know about.
|
|
67
|
+
const testNotesTable = defineUnmanagedTable({
|
|
68
|
+
tableName: "test_notes",
|
|
69
|
+
columns: [
|
|
70
|
+
{ name: "tenant_id", pgType: "uuid", notNull: true },
|
|
71
|
+
{ name: "author_id", pgType: "text", notNull: true },
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
74
|
|
|
75
75
|
// Synthetic third-party Domain-Feature: "note" mit export- + delete-Hook.
|
|
76
76
|
// Stellvertretend fuer App-spezifische Entities (Chat-Message, Blog-Post
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Datei ihre Bytes dauerhaft auf Disk (Issue gefunden im Review zu #177).
|
|
7
7
|
|
|
8
8
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
-
import { asRawClient
|
|
9
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
10
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
11
11
|
import {
|
|
12
12
|
createInMemoryFileProvider,
|
|
@@ -20,31 +20,32 @@ import {
|
|
|
20
20
|
unsafePushTables,
|
|
21
21
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
22
|
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
23
|
-
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
24
23
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
25
24
|
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
26
25
|
import { createFilesFeature } from "../../files";
|
|
27
26
|
import { createSessionsFeature } from "../../sessions";
|
|
28
|
-
import { createUserFeature,
|
|
27
|
+
import { createUserFeature, userEntity, userTable } from "../../user";
|
|
29
28
|
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
30
29
|
import { createUserDataRightsFeature } from "../feature";
|
|
31
30
|
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
31
|
+
import {
|
|
32
|
+
createForgetSeeders,
|
|
33
|
+
type ForgetSeeders,
|
|
34
|
+
nowInstant,
|
|
35
|
+
READ_TENANT_MEMBERSHIPS_DDL,
|
|
36
|
+
} from "./forget-test-helpers";
|
|
32
37
|
|
|
33
38
|
let stack: TestStack;
|
|
34
39
|
let db: DbConnection;
|
|
35
40
|
let provider: InMemoryFileProvider;
|
|
41
|
+
let seed: ForgetSeeders;
|
|
36
42
|
|
|
37
43
|
const TENANT = "00000000-0000-4000-8000-00000000000c";
|
|
38
|
-
const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
39
44
|
|
|
40
45
|
function uuid(suffix: number): string {
|
|
41
46
|
return `bbbbbbbb-bbbb-4bbb-8bbb-${suffix.toString(16).padStart(12, "0")}`;
|
|
42
47
|
}
|
|
43
48
|
|
|
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
49
|
beforeAll(async () => {
|
|
49
50
|
provider = createInMemoryFileProvider();
|
|
50
51
|
stack = await setupTestStack({
|
|
@@ -60,27 +61,12 @@ beforeAll(async () => {
|
|
|
60
61
|
files: { storageProvider: provider },
|
|
61
62
|
});
|
|
62
63
|
db = stack.db;
|
|
64
|
+
seed = createForgetSeeders(db, provider);
|
|
63
65
|
|
|
64
66
|
await unsafeCreateEntityTable(db, userEntity);
|
|
65
67
|
await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
|
|
66
68
|
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
|
-
`);
|
|
69
|
+
await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
84
70
|
});
|
|
85
71
|
|
|
86
72
|
afterAll(async () => {
|
|
@@ -92,49 +78,15 @@ beforeEach(async () => {
|
|
|
92
78
|
await resetTestTables(db, [userTable, "read_tenant_memberships", fileRefsTable]);
|
|
93
79
|
});
|
|
94
80
|
|
|
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
81
|
describe("forget-binary-cleanup :: storage.delete fires before row hard-delete", () => {
|
|
130
82
|
test("Forget deletes the binary from the storage provider", async () => {
|
|
131
83
|
const userId = uuid(1);
|
|
132
|
-
await seedForgetUser(userId);
|
|
133
|
-
await seedMembership(userId, TENANT);
|
|
134
|
-
const key = await seedFile(uuid(101), TENANT, userId);
|
|
84
|
+
await seed.seedForgetUser(userId);
|
|
85
|
+
await seed.seedMembership(userId, TENANT);
|
|
86
|
+
const key = await seed.seedFile(uuid(101), TENANT, userId);
|
|
135
87
|
expect(await provider.exists(key)).toBe(true);
|
|
136
88
|
|
|
137
|
-
const result = await runForgetCleanup({ db, registry: stack.registry, now:
|
|
89
|
+
const result = await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
|
|
138
90
|
|
|
139
91
|
expect(result.processedUserIds).toContain(userId);
|
|
140
92
|
expect(await provider.exists(key)).toBe(false);
|
|
@@ -143,16 +95,16 @@ describe("forget-binary-cleanup :: storage.delete fires before row hard-delete",
|
|
|
143
95
|
|
|
144
96
|
test("Multiple files from the same user — all binaries cleaned up", async () => {
|
|
145
97
|
const userId = uuid(2);
|
|
146
|
-
await seedForgetUser(userId);
|
|
147
|
-
await seedMembership(userId, TENANT);
|
|
98
|
+
await seed.seedForgetUser(userId);
|
|
99
|
+
await seed.seedMembership(userId, TENANT);
|
|
148
100
|
const keys = await Promise.all([
|
|
149
|
-
seedFile(uuid(201), TENANT, userId),
|
|
150
|
-
seedFile(uuid(202), TENANT, userId),
|
|
151
|
-
seedFile(uuid(203), TENANT, userId),
|
|
101
|
+
seed.seedFile(uuid(201), TENANT, userId),
|
|
102
|
+
seed.seedFile(uuid(202), TENANT, userId),
|
|
103
|
+
seed.seedFile(uuid(203), TENANT, userId),
|
|
152
104
|
]);
|
|
153
105
|
expect(provider.keys()).toHaveLength(3);
|
|
154
106
|
|
|
155
|
-
await runForgetCleanup({ db, registry: stack.registry, now:
|
|
107
|
+
await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
|
|
156
108
|
|
|
157
109
|
for (const key of keys) {
|
|
158
110
|
expect(await provider.exists(key)).toBe(false);
|
|
@@ -163,14 +115,14 @@ describe("forget-binary-cleanup :: storage.delete fires before row hard-delete",
|
|
|
163
115
|
test("Other tenants' files stay untouched", async () => {
|
|
164
116
|
const userId = uuid(3);
|
|
165
117
|
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");
|
|
118
|
+
await seed.seedForgetUser(userId);
|
|
119
|
+
await seed.seedMembership(userId, TENANT);
|
|
120
|
+
const myKey = await seed.seedFile(uuid(301), TENANT, userId);
|
|
121
|
+
const otherKey = await seed.seedFile(uuid(302), otherTenant, "another-user");
|
|
170
122
|
// The other-tenant file is owned by a different user; the forget run for
|
|
171
123
|
// userId must NOT touch it.
|
|
172
124
|
|
|
173
|
-
await runForgetCleanup({ db, registry: stack.registry, now:
|
|
125
|
+
await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
|
|
174
126
|
|
|
175
127
|
expect(await provider.exists(myKey)).toBe(false);
|
|
176
128
|
expect(await provider.exists(otherKey)).toBe(true);
|