@cosmicdrift/kumiko-bundled-features 0.87.3 → 0.89.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 +6 -6
- package/src/data-retention/__tests__/cleanup-cron-registration.test.ts +23 -0
- package/src/data-retention/__tests__/resolve-tenant-preset.test.ts +57 -0
- package/src/data-retention/__tests__/resolver.test.ts +3 -3
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +188 -0
- package/src/data-retention/feature.ts +58 -7
- package/src/data-retention/presets.ts +5 -5
- package/src/data-retention/resolve-for-tenant.ts +9 -4
- package/src/data-retention/resolve-tenant-preset.ts +51 -0
- package/src/data-retention/run-retention-cleanup.ts +151 -0
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +8 -1
- package/src/legal-pages/feature.ts +22 -10
- package/src/mail-foundation/feature.ts +51 -6
- package/src/mail-foundation/index.ts +2 -0
- package/src/mail-transport-inmemory/feature.ts +6 -3
- package/src/mail-transport-smtp/feature.ts +11 -10
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +1 -1
- package/src/managed-pages/feature.ts +17 -9
- package/src/user-data-rights/__tests__/default-mailers.test.ts +135 -0
- package/src/user-data-rights/__tests__/email-templates.test.ts +85 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +65 -0
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +154 -0
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +23 -2
- package/src/user-data-rights/email-templates.ts +211 -0
- package/src/user-data-rights/feature.ts +110 -21
- package/src/user-data-rights/handlers/deletion-grace-period.ts +17 -5
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +11 -0
- package/src/user-data-rights/handlers/export-job-detail.query.ts +7 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +8 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +29 -3
- package/src/user-data-rights/lib/default-mailers.ts +116 -0
- package/src/user-data-rights/lib/mail-transport-resolver.ts +50 -0
- package/src/user-data-rights/run-export-jobs.ts +19 -8
- package/src/user-data-rights/run-forget-cleanup.ts +11 -1
- package/src/user-data-rights/screens.ts +71 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
renderDeletionExecutedEmail,
|
|
4
|
+
renderDeletionRequestedEmail,
|
|
5
|
+
renderExportFailedEmail,
|
|
6
|
+
renderExportReadyEmail,
|
|
7
|
+
} from "../email-templates";
|
|
8
|
+
|
|
9
|
+
describe("gdpr email-templates", () => {
|
|
10
|
+
test("export-ready: subject + download-button + formatted expiry, de/en differ", () => {
|
|
11
|
+
const en = renderExportReadyEmail({
|
|
12
|
+
downloadUrl: "https://app.test/export/by-token?token=abc123",
|
|
13
|
+
expiresAt: "2026-07-01T13:45:00Z",
|
|
14
|
+
locale: "en",
|
|
15
|
+
appName: "Acme",
|
|
16
|
+
});
|
|
17
|
+
expect(en.subject).toBe("Acme — Your data export is ready");
|
|
18
|
+
expect(en.html).toContain("https://app.test/export/by-token?token=abc123");
|
|
19
|
+
// Button label present.
|
|
20
|
+
expect(en.html).toContain("Download data export");
|
|
21
|
+
// Instant formatted to UTC, not the raw ISO.
|
|
22
|
+
expect(en.html).toContain("2026-07-01 13:45 UTC");
|
|
23
|
+
|
|
24
|
+
const de = renderExportReadyEmail({
|
|
25
|
+
downloadUrl: "https://app.test/x?token=abc",
|
|
26
|
+
expiresAt: "2026-07-01T13:45:00Z",
|
|
27
|
+
locale: "de",
|
|
28
|
+
appName: "Acme",
|
|
29
|
+
});
|
|
30
|
+
expect(de.subject).toBe("Acme — Dein Datenexport ist bereit");
|
|
31
|
+
expect(de.subject).not.toBe(en.subject);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("export-ready: ampersand in download url is escaped in the href attr", () => {
|
|
35
|
+
const r = renderExportReadyEmail({
|
|
36
|
+
downloadUrl: "https://app.test/x?token=a&next=b",
|
|
37
|
+
expiresAt: "2026-07-01T13:45:00Z",
|
|
38
|
+
});
|
|
39
|
+
// escapeHtmlAttr turns & into & — no raw unescaped attribute break.
|
|
40
|
+
expect(r.html).toContain("token=a&next=b");
|
|
41
|
+
expect(r.html).not.toContain('token=a&next="');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("export-ready: default appName when omitted (Account/Konto)", () => {
|
|
45
|
+
expect(
|
|
46
|
+
renderExportReadyEmail({ downloadUrl: "u", expiresAt: "x", locale: "en" }).subject,
|
|
47
|
+
).toContain("Account");
|
|
48
|
+
expect(
|
|
49
|
+
renderExportReadyEmail({ downloadUrl: "u", expiresAt: "x", locale: "de" }).subject,
|
|
50
|
+
).toContain("Konto");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("export-failed: informational, no download button", () => {
|
|
54
|
+
const r = renderExportFailedEmail({ locale: "en", appName: "Acme" });
|
|
55
|
+
expect(r.subject).toBe("Acme — Your data export failed");
|
|
56
|
+
expect(r.html).not.toContain("<a ");
|
|
57
|
+
expect(r.html).toContain("request the export again");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("deletion-requested: grace deadline formatted + cancel hint", () => {
|
|
61
|
+
const r = renderDeletionRequestedEmail({
|
|
62
|
+
gracePeriodEnd: "2026-07-30T09:00:00Z",
|
|
63
|
+
locale: "en",
|
|
64
|
+
appName: "Acme",
|
|
65
|
+
});
|
|
66
|
+
expect(r.subject).toBe("Acme — Account deletion requested");
|
|
67
|
+
expect(r.html).toContain("2026-07-30 09:00 UTC");
|
|
68
|
+
expect(r.html).toContain("cancel the deletion");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("deletion-executed: execution timestamp formatted", () => {
|
|
72
|
+
const r = renderDeletionExecutedEmail({
|
|
73
|
+
executedAt: "2026-07-30T09:05:00Z",
|
|
74
|
+
locale: "de",
|
|
75
|
+
appName: "Acme",
|
|
76
|
+
});
|
|
77
|
+
expect(r.subject).toBe("Acme — Dein Konto wurde geloescht");
|
|
78
|
+
expect(r.html).toContain("2026-07-30 09:05 UTC");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("un-parsable timestamp falls back to the raw string", () => {
|
|
82
|
+
const r = renderDeletionExecutedEmail({ executedAt: "not-a-date" });
|
|
83
|
+
expect(r.html).toContain("not-a-date");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles/feature";
|
|
4
|
+
import { createConfigFeature } from "../../config/feature";
|
|
5
|
+
import { createDataRetentionFeature } from "../../data-retention/feature";
|
|
6
|
+
import { createSessionsFeature } from "../../sessions/feature";
|
|
7
|
+
import { createUserFeature } from "../../user/feature";
|
|
8
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
9
|
+
|
|
10
|
+
// Read-only GDPR inspector screens live IN user-data-rights (the boot-validator
|
|
11
|
+
// forbids cross-feature screen ownership). The validator checks screen structure
|
|
12
|
+
// — entity-local binding, columns/fields exist on the entity, rowAction targets
|
|
13
|
+
// resolve — so a clean boot proves the entityList/entityEdit defs bind to the
|
|
14
|
+
// real event-sourced entities and the convention list/detail QNs exist. The
|
|
15
|
+
// entities (export-job, download-attempt) are r.entity, not direct-write stores,
|
|
16
|
+
// so an entityList binding is rebuild-safe (unlike jobs/sessions read-models).
|
|
17
|
+
|
|
18
|
+
describe("user-data-rights read-only inspector screens", () => {
|
|
19
|
+
const features = [
|
|
20
|
+
createConfigFeature(),
|
|
21
|
+
createUserFeature(),
|
|
22
|
+
createSessionsFeature(),
|
|
23
|
+
createDataRetentionFeature(),
|
|
24
|
+
createComplianceProfilesFeature(),
|
|
25
|
+
createUserDataRightsFeature(),
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
test("the assembled feature set boot-validates", () => {
|
|
29
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("ships SystemAdmin-gated list + detail screens", () => {
|
|
33
|
+
const f = createUserDataRightsFeature();
|
|
34
|
+
expect(Object.keys(f.screens)).toEqual(
|
|
35
|
+
expect.arrayContaining(["export-job-list", "export-job-detail", "download-attempt-list"]),
|
|
36
|
+
);
|
|
37
|
+
const list = f.screens["export-job-list"];
|
|
38
|
+
expect(list?.type).toBe("entityList");
|
|
39
|
+
expect(list?.access).toEqual({ roles: ["SystemAdmin"] });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("export-job detail is strictly read-only (no create/delete, every field readOnly)", () => {
|
|
43
|
+
const f = createUserDataRightsFeature();
|
|
44
|
+
const edit = f.screens["export-job-detail"];
|
|
45
|
+
expect(edit?.type).toBe("entityEdit");
|
|
46
|
+
if (edit?.type === "entityEdit") {
|
|
47
|
+
expect(edit.allowCreate).toBe(false);
|
|
48
|
+
expect(edit.allowDelete).toBe(false);
|
|
49
|
+
const fields = edit.layout.sections.flatMap((s) => ("fields" in s ? s.fields : []));
|
|
50
|
+
expect(fields.length).toBeGreaterThan(0);
|
|
51
|
+
expect(fields.every((field) => typeof field === "object" && field.readOnly === true)).toBe(
|
|
52
|
+
true,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("convention list/detail handlers resolve the screen QNs", () => {
|
|
58
|
+
const f = createUserDataRightsFeature();
|
|
59
|
+
// entityList → user-data-rights:query:export-job:list; entityEdit detail →
|
|
60
|
+
// :export-job:detail; download-attempt list → :download-attempt:list.
|
|
61
|
+
expect(Object.keys(f.queryHandlers)).toEqual(
|
|
62
|
+
expect.arrayContaining(["export-job:list", "export-job:detail", "download-attempt:list"]),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// C6 Autonomie-Beweis: mountet user-data-rights OHNE send*Email-Opts +
|
|
2
|
+
// mail-foundation + mail-transport-inmemory, und treibt den ECHTEN
|
|
3
|
+
// registrierten Forget-Cron durch einen echten Job-Kontext — `configResolver`
|
|
4
|
+
// gesetzt (App-Override provider=inmemory), KEIN per-request `config`.
|
|
5
|
+
//
|
|
6
|
+
// Das beweist die kritische Naht: der Cron baut den per-Tenant-Mail-Transport
|
|
7
|
+
// aus `ctx.configResolver` (makeTenantMailTransportResolver). Ein hand-
|
|
8
|
+
// gefuetterter Callback oder Fake-Resolver wuerde genau diese Bruecke
|
|
9
|
+
// ueberspringen (vgl. project_export_cron_config_accessor_fix). Akzeptanz von
|
|
10
|
+
// #624: "App mountet mail-foundation+transport → GDPR-Mails ohne Callback-Code".
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
15
|
+
import {
|
|
16
|
+
setupTestStack,
|
|
17
|
+
type TestStack,
|
|
18
|
+
unsafeCreateEntityTable,
|
|
19
|
+
unsafePushTables,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
22
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
23
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
24
|
+
import { configValuesTable, createConfigFeature, createConfigResolver } from "../../config";
|
|
25
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
26
|
+
import { createFilesFeature } from "../../files";
|
|
27
|
+
import { mailFoundationFeature } from "../../mail-foundation";
|
|
28
|
+
import { clearInbox, getInbox, mailTransportInMemoryFeature } from "../../mail-transport-inmemory";
|
|
29
|
+
import { createSessionsFeature } from "../../sessions";
|
|
30
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
31
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
32
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
33
|
+
|
|
34
|
+
const TENANT_A = "00000000-0000-4000-8000-0000000006a1";
|
|
35
|
+
const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
36
|
+
const USER_ID = "00000000-0000-4000-8000-0000000006b1";
|
|
37
|
+
const ORIGINAL_EMAIL = "bridge-delete@example.test";
|
|
38
|
+
const FORGET_JOB = "user-data-rights:job:run-forget-cleanup";
|
|
39
|
+
|
|
40
|
+
// App-weiter Override (wie money-horse's Config-Resolver): provider=inmemory
|
|
41
|
+
// ohne per-Tenant-config-Row. Der Job-Kontext traegt DIESEN resolver, kein config.
|
|
42
|
+
const configResolver = createConfigResolver({
|
|
43
|
+
appOverrides: new Map([["mail-foundation:config:provider", "inmemory"]]),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
let stack: TestStack;
|
|
47
|
+
|
|
48
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
49
|
+
const past = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
50
|
+
|
|
51
|
+
beforeAll(async () => {
|
|
52
|
+
stack = await setupTestStack({
|
|
53
|
+
features: [
|
|
54
|
+
createConfigFeature(),
|
|
55
|
+
createUserFeature(),
|
|
56
|
+
createFilesFeature(),
|
|
57
|
+
createDataRetentionFeature(),
|
|
58
|
+
createComplianceProfilesFeature(),
|
|
59
|
+
createSessionsFeature(),
|
|
60
|
+
mailFoundationFeature,
|
|
61
|
+
mailTransportInMemoryFeature,
|
|
62
|
+
// KEINE send*Email-Opts — die mail-foundation-Defaults muessen greifen.
|
|
63
|
+
createUserDataRightsFeature(),
|
|
64
|
+
createUserDataRightsDefaultsFeature(),
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
69
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
70
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
71
|
+
await asRawClient(stack.db).unsafe(`
|
|
72
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
73
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
74
|
+
tenant_id UUID NOT NULL,
|
|
75
|
+
user_id TEXT NOT NULL,
|
|
76
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
78
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
79
|
+
inserted_by_id TEXT,
|
|
80
|
+
modified_by_id TEXT,
|
|
81
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
82
|
+
deleted_at TIMESTAMPTZ,
|
|
83
|
+
deleted_by_id TEXT,
|
|
84
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
85
|
+
UNIQUE(user_id, tenant_id)
|
|
86
|
+
)
|
|
87
|
+
`);
|
|
88
|
+
await unsafePushTables(stack.db, { fileRefsTable });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterAll(async () => {
|
|
92
|
+
await stack.cleanup();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
await resetTestTables(stack.db, [userTable, "read_tenant_memberships", fileRefsTable]);
|
|
97
|
+
clearInbox(TENANT_A);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("C6 default mail bridge :: forget cron sends deletion-executed without app callback", () => {
|
|
101
|
+
test("registered cron + configResolver(provider=inmemory) → user deleted + mail in inbox", async () => {
|
|
102
|
+
await insertOne(stack.db, userTable, {
|
|
103
|
+
id: USER_ID,
|
|
104
|
+
tenantId: TENANT_SYSTEM,
|
|
105
|
+
email: ORIGINAL_EMAIL,
|
|
106
|
+
passwordHash: "hashed",
|
|
107
|
+
displayName: "Bridge Delete",
|
|
108
|
+
locale: "de",
|
|
109
|
+
emailVerified: true,
|
|
110
|
+
roles: '["Member"]',
|
|
111
|
+
status: USER_STATUS.DeletionRequested,
|
|
112
|
+
gracePeriodEnd: past(),
|
|
113
|
+
});
|
|
114
|
+
await asRawClient(stack.db).unsafe(
|
|
115
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles) VALUES ($1, $2, '["Member"]')`,
|
|
116
|
+
[TENANT_A, USER_ID],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const job = stack.registry.getJob(FORGET_JOB);
|
|
120
|
+
expect(job).toBeDefined();
|
|
121
|
+
|
|
122
|
+
// EXAKT der prod-Job-Kontext: configResolver gesetzt, config undefined.
|
|
123
|
+
const jobCtx = { db: stack.db, registry: stack.registry, configResolver };
|
|
124
|
+
await job?.handler({}, jobCtx as never);
|
|
125
|
+
|
|
126
|
+
// Loeschung lief autonom durch.
|
|
127
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
128
|
+
"SELECT status, email FROM read_users WHERE id = $1",
|
|
129
|
+
[USER_ID],
|
|
130
|
+
)) as unknown as { rows?: Array<{ status: string; email: string }> };
|
|
131
|
+
const userRow = (rows.rows ?? (rows as unknown as Array<{ status: string; email: string }>))[0];
|
|
132
|
+
expect(userRow?.status).toBe(USER_STATUS.Deleted);
|
|
133
|
+
|
|
134
|
+
// Die Default-Mail wurde ueber den aus configResolver gebauten inmemory-
|
|
135
|
+
// Transport versendet — keine App-seitige Callback-Verdrahtung.
|
|
136
|
+
const inbox = getInbox(TENANT_A);
|
|
137
|
+
expect(inbox).toHaveLength(1);
|
|
138
|
+
expect(inbox[0]?.to).toBe(ORIGINAL_EMAIL);
|
|
139
|
+
// Der User ist mit locale="de" geseedet → die Default-Mail rendert DEUTSCH
|
|
140
|
+
// (per-recipient locale, KEIN App-Callback). Vorher rendete sie still en —
|
|
141
|
+
// der Advisor-Befund, den dieser Assert jetzt einfaengt.
|
|
142
|
+
expect(inbox[0]?.subject).toContain("Dein Konto wurde geloescht");
|
|
143
|
+
expect(inbox[0]?.html).toContain("endgueltig");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("no mail transport mounted is NOT this stack — sanity: provider really is inmemory", () => {
|
|
147
|
+
// Pinnt dass der Stack den inmemory-Transport registriert hat (sonst waere
|
|
148
|
+
// der obige Beweis vacuously true: ohne mailTransport-Usage greift die
|
|
149
|
+
// Default gar nicht).
|
|
150
|
+
expect(stack.registry.getExtensionUsages("mailTransport").map((u) => u.entityName)).toContain(
|
|
151
|
+
"inmemory",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -28,6 +28,8 @@ import { configValuesTable, createConfigFeature, createConfigResolver } from "..
|
|
|
28
28
|
import { createDataRetentionFeature } from "../../data-retention";
|
|
29
29
|
import { fileFoundationFeature } from "../../file-foundation";
|
|
30
30
|
import { fileProviderInMemoryFeature } from "../../file-provider-inmemory";
|
|
31
|
+
import { mailFoundationFeature } from "../../mail-foundation";
|
|
32
|
+
import { clearInbox, getInbox, mailTransportInMemoryFeature } from "../../mail-transport-inmemory";
|
|
31
33
|
import { createSessionsFeature } from "../../sessions";
|
|
32
34
|
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
33
35
|
import { createUserDataRightsFeature } from "../feature";
|
|
@@ -41,9 +43,14 @@ const JOB_QN = "user-data-rights:job:run-export-jobs";
|
|
|
41
43
|
// App-weiter Override wie money-horse's cashColtConfigResolver — provider=inmemory
|
|
42
44
|
// ohne per-Tenant-config-Row. Der Job-Kontext trägt DIESEN resolver, kein config.
|
|
43
45
|
const configResolver = createConfigResolver({
|
|
44
|
-
appOverrides: new Map([
|
|
46
|
+
appOverrides: new Map([
|
|
47
|
+
["file-foundation:config:provider", "inmemory"],
|
|
48
|
+
["mail-foundation:config:provider", "inmemory"],
|
|
49
|
+
]),
|
|
45
50
|
});
|
|
46
51
|
|
|
52
|
+
const EXPORT_DOWNLOAD_URL = "https://app.test/user-export/by-token";
|
|
53
|
+
|
|
47
54
|
let stack: TestStack;
|
|
48
55
|
|
|
49
56
|
beforeAll(async () => {
|
|
@@ -55,8 +62,12 @@ beforeAll(async () => {
|
|
|
55
62
|
createComplianceProfilesFeature(),
|
|
56
63
|
fileFoundationFeature,
|
|
57
64
|
fileProviderInMemoryFeature,
|
|
65
|
+
mailFoundationFeature,
|
|
66
|
+
mailTransportInMemoryFeature,
|
|
58
67
|
createSessionsFeature(),
|
|
59
|
-
|
|
68
|
+
// appExportDownloadUrl set → the default export-ready mail is enabled, so
|
|
69
|
+
// this also proves the export cron's mail bridge end-to-end (C6).
|
|
70
|
+
createUserDataRightsFeature({ appExportDownloadUrl: EXPORT_DOWNLOAD_URL }),
|
|
60
71
|
],
|
|
61
72
|
});
|
|
62
73
|
await createEventsTable(stack.db);
|
|
@@ -107,6 +118,7 @@ beforeEach(async () => {
|
|
|
107
118
|
await raw.unsafe(
|
|
108
119
|
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles) VALUES ('${TENANT}', '${USER_ID}', '["Member"]')`,
|
|
109
120
|
);
|
|
121
|
+
clearInbox(TENANT);
|
|
110
122
|
});
|
|
111
123
|
|
|
112
124
|
// Seedet einen pending Export-Job über den echten request-export-Handler.
|
|
@@ -144,5 +156,14 @@ describe("run-export-jobs cron-context", () => {
|
|
|
144
156
|
expect(row?.errorMessage).toBeNull();
|
|
145
157
|
expect(row?.status).toBe(EXPORT_JOB_STATUS.Done);
|
|
146
158
|
expect(row?.bytesWritten ?? 0).toBeGreaterThan(0);
|
|
159
|
+
|
|
160
|
+
// C6 — der echte Export-Cron versendet die Default-Export-ready-Mail ueber
|
|
161
|
+
// den aus configResolver gebauten inmemory-Transport (kein App-Callback).
|
|
162
|
+
// user.locale="de" → deutsches Subject; downloadUrl traegt den Magic-Link.
|
|
163
|
+
const inbox = getInbox(TENANT);
|
|
164
|
+
expect(inbox).toHaveLength(1);
|
|
165
|
+
expect(inbox[0]?.to).toBe("export-cron@example.test");
|
|
166
|
+
expect(inbox[0]?.subject).toContain("Dein Datenexport ist bereit");
|
|
167
|
+
expect(inbox[0]?.html).toContain(EXPORT_DOWNLOAD_URL);
|
|
147
168
|
});
|
|
148
169
|
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Default-HTML-Renderer fuer die GDPR-Notification-Mails (Export-bereit,
|
|
2
|
+
// Export-fehlgeschlagen, Loeschung-angefordert, Loeschung-ausgefuehrt).
|
|
3
|
+
// Damit eine App keine eigenen send*Email-Callbacks schreiben muss: sie
|
|
4
|
+
// mountet mail-foundation + einen mail-transport-* und user-data-rights
|
|
5
|
+
// rendert + versendet ueber diese Templates (siehe lib/default-mailers.ts).
|
|
6
|
+
//
|
|
7
|
+
// Apps die ihr eigenes Branding wollen, uebergeben eigene send*Email-Opts —
|
|
8
|
+
// dann greifen diese Defaults nicht. Pattern + plain-inline-HTML gespiegelt
|
|
9
|
+
// von auth-email-password/email-templates.ts (kein CSS-Framework, kein
|
|
10
|
+
// Bild-Asset; Mail-Clients rendern table-layout + inline-CSS verlaesslich).
|
|
11
|
+
//
|
|
12
|
+
// Locale: de + en. Apps mit anderen Sprachen uebergeben eigene Callbacks.
|
|
13
|
+
|
|
14
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
15
|
+
import { Temporal } from "temporal-polyfill";
|
|
16
|
+
|
|
17
|
+
export type GdprMailLocale = "de" | "en";
|
|
18
|
+
|
|
19
|
+
// user.locale ist Freitext ("de", "en", "de-DE", "fr", null). Die Templates
|
|
20
|
+
// koennen nur de/en — alles andere (inkl. unbekannte Sprachen) faellt auf
|
|
21
|
+
// undefined zurueck, sodass der Caller auf mailDefaults.locale bzw. "en" geht.
|
|
22
|
+
export function normalizeGdprMailLocale(
|
|
23
|
+
raw: string | null | undefined,
|
|
24
|
+
): GdprMailLocale | undefined {
|
|
25
|
+
if (!raw) return undefined;
|
|
26
|
+
const lower = raw.toLowerCase();
|
|
27
|
+
if (lower.startsWith("de")) return "de";
|
|
28
|
+
if (lower.startsWith("en")) return "en";
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type RenderedEmail = {
|
|
33
|
+
readonly subject: string;
|
|
34
|
+
readonly html: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type RenderExportReadyEmailArgs = {
|
|
38
|
+
readonly downloadUrl: string;
|
|
39
|
+
readonly expiresAt: string;
|
|
40
|
+
readonly locale?: GdprMailLocale;
|
|
41
|
+
readonly appName?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type RenderExportFailedEmailArgs = {
|
|
45
|
+
readonly locale?: GdprMailLocale;
|
|
46
|
+
readonly appName?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type RenderDeletionRequestedEmailArgs = {
|
|
50
|
+
readonly gracePeriodEnd: string;
|
|
51
|
+
readonly locale?: GdprMailLocale;
|
|
52
|
+
readonly appName?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type RenderDeletionExecutedEmailArgs = {
|
|
56
|
+
readonly executedAt: string;
|
|
57
|
+
readonly locale?: GdprMailLocale;
|
|
58
|
+
readonly appName?: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const STRINGS = {
|
|
62
|
+
de: {
|
|
63
|
+
greeting: "Hallo,",
|
|
64
|
+
exportReadySubject: (app: string) => `${app} — Dein Datenexport ist bereit`,
|
|
65
|
+
exportReadyIntro: (app: string) =>
|
|
66
|
+
`dein angeforderter Datenexport fuer ${app} ist fertig. Lade ihn ueber den folgenden Link herunter:`,
|
|
67
|
+
exportReadyButton: "Datenexport herunterladen",
|
|
68
|
+
exportReadyExpiry: (when: string) => `Der Download-Link laeuft am ${when} ab.`,
|
|
69
|
+
exportFailedSubject: (app: string) => `${app} — Dein Datenexport ist fehlgeschlagen`,
|
|
70
|
+
exportFailedIntro: (app: string) =>
|
|
71
|
+
`dein angeforderter Datenexport fuer ${app} konnte leider nicht erstellt werden. Bitte fordere den Export erneut an.`,
|
|
72
|
+
deletionRequestedSubject: (app: string) => `${app} — Loeschung deines Kontos angefordert`,
|
|
73
|
+
deletionRequestedIntro: (app: string, when: string) =>
|
|
74
|
+
`wir haben deinen Antrag zur Loeschung deines ${app}-Kontos erhalten. Dein Konto und die zugehoerigen Daten werden am ${when} endgueltig geloescht.`,
|
|
75
|
+
deletionRequestedCancel:
|
|
76
|
+
"Falls du das nicht angefordert hast, melde dich an und brich die Loeschung in den Kontoeinstellungen ab, bevor die Frist ablaeuft.",
|
|
77
|
+
deletionExecutedSubject: (app: string) => `${app} — Dein Konto wurde geloescht`,
|
|
78
|
+
deletionExecutedIntro: (app: string, when: string) =>
|
|
79
|
+
`dein ${app}-Konto und die zugehoerigen personenbezogenen Daten wurden am ${when} geloescht. Diese Aktion ist endgueltig.`,
|
|
80
|
+
fallbackUrl: "Falls der Button nicht funktioniert, kopiere diesen Link in den Browser:",
|
|
81
|
+
},
|
|
82
|
+
en: {
|
|
83
|
+
greeting: "Hi,",
|
|
84
|
+
exportReadySubject: (app: string) => `${app} — Your data export is ready`,
|
|
85
|
+
exportReadyIntro: (app: string) =>
|
|
86
|
+
`your requested data export for ${app} is ready. Download it using the link below:`,
|
|
87
|
+
exportReadyButton: "Download data export",
|
|
88
|
+
exportReadyExpiry: (when: string) => `The download link expires on ${when}.`,
|
|
89
|
+
exportFailedSubject: (app: string) => `${app} — Your data export failed`,
|
|
90
|
+
exportFailedIntro: (app: string) =>
|
|
91
|
+
`your requested data export for ${app} could not be created. Please request the export again.`,
|
|
92
|
+
deletionRequestedSubject: (app: string) => `${app} — Account deletion requested`,
|
|
93
|
+
deletionRequestedIntro: (app: string, when: string) =>
|
|
94
|
+
`we received your request to delete your ${app} account. Your account and associated data will be permanently deleted on ${when}.`,
|
|
95
|
+
deletionRequestedCancel:
|
|
96
|
+
"If you didn't request this, sign in and cancel the deletion in your account settings before the deadline.",
|
|
97
|
+
deletionExecutedSubject: (app: string) => `${app} — Your account has been deleted`,
|
|
98
|
+
deletionExecutedIntro: (app: string, when: string) =>
|
|
99
|
+
`your ${app} account and the associated personal data were deleted on ${when}. This action is permanent.`,
|
|
100
|
+
fallbackUrl: "If the button doesn't work, copy this link into your browser:",
|
|
101
|
+
},
|
|
102
|
+
} as const;
|
|
103
|
+
|
|
104
|
+
function appNameFor(args: { locale?: GdprMailLocale; appName?: string }): string {
|
|
105
|
+
const locale = args.locale ?? "en";
|
|
106
|
+
return args.appName ?? (locale === "de" ? "Konto" : "Account");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function renderExportReadyEmail(args: RenderExportReadyEmailArgs): RenderedEmail {
|
|
110
|
+
const locale = args.locale ?? "en";
|
|
111
|
+
const app = appNameFor(args);
|
|
112
|
+
const t = STRINGS[locale];
|
|
113
|
+
const subject = t.exportReadySubject(app);
|
|
114
|
+
const body = `
|
|
115
|
+
<p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
|
|
116
|
+
<p style="margin: 0 0 24px; font-size: 14px; line-height: 1.5;">${escapeHtml(t.exportReadyIntro(app))}</p>
|
|
117
|
+
<p style="margin: 0 0 24px;">${renderButton({ url: args.downloadUrl, label: t.exportReadyButton })}</p>
|
|
118
|
+
<p style="margin: 0 0 8px; font-size: 13px; color: #555;">${escapeHtml(t.exportReadyExpiry(formatTimestamp(args.expiresAt)))}</p>
|
|
119
|
+
${renderFallbackUrl({ url: args.downloadUrl, label: t.fallbackUrl })}`;
|
|
120
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body) }) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function renderExportFailedEmail(args: RenderExportFailedEmailArgs): RenderedEmail {
|
|
124
|
+
const locale = args.locale ?? "en";
|
|
125
|
+
const app = appNameFor(args);
|
|
126
|
+
const t = STRINGS[locale];
|
|
127
|
+
const subject = t.exportFailedSubject(app);
|
|
128
|
+
const body = `
|
|
129
|
+
<p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
|
|
130
|
+
<p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t.exportFailedIntro(app))}</p>`;
|
|
131
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body) }) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function renderDeletionRequestedEmail(
|
|
135
|
+
args: RenderDeletionRequestedEmailArgs,
|
|
136
|
+
): RenderedEmail {
|
|
137
|
+
const locale = args.locale ?? "en";
|
|
138
|
+
const app = appNameFor(args);
|
|
139
|
+
const t = STRINGS[locale];
|
|
140
|
+
const subject = t.deletionRequestedSubject(app);
|
|
141
|
+
const body = `
|
|
142
|
+
<p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
|
|
143
|
+
<p style="margin: 0 0 16px; font-size: 14px; line-height: 1.5;">${escapeHtml(t.deletionRequestedIntro(app, formatTimestamp(args.gracePeriodEnd)))}</p>
|
|
144
|
+
<p style="margin: 0; font-size: 13px; color: #555;">${escapeHtml(t.deletionRequestedCancel)}</p>`;
|
|
145
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body) }) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function renderDeletionExecutedEmail(args: RenderDeletionExecutedEmailArgs): RenderedEmail {
|
|
149
|
+
const locale = args.locale ?? "en";
|
|
150
|
+
const app = appNameFor(args);
|
|
151
|
+
const t = STRINGS[locale];
|
|
152
|
+
const subject = t.deletionExecutedSubject(app);
|
|
153
|
+
const body = `
|
|
154
|
+
<p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
|
|
155
|
+
<p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t.deletionExecutedIntro(app, formatTimestamp(args.executedAt)))}</p>`;
|
|
156
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body) }) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function wrapCell(bodyHtml: string): string {
|
|
160
|
+
return `<tr><td>${bodyHtml}</td></tr>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Plain inline-styled HTML — gespiegelt von auth-email-password.
|
|
164
|
+
// guard:dup-ok — Email-HTML (table-layout, inline CSS) ≠ Web-HTML (legal-pages/markdown.ts)
|
|
165
|
+
function renderShell(args: { title: string; bodyHtml: string }): string {
|
|
166
|
+
return `<!DOCTYPE html>
|
|
167
|
+
<html lang="en">
|
|
168
|
+
<head>
|
|
169
|
+
<meta charset="utf-8" />
|
|
170
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
171
|
+
<title>${escapeHtml(args.title)}</title>
|
|
172
|
+
</head>
|
|
173
|
+
<body style="margin: 0; padding: 0; background: #f7f7f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a;">
|
|
174
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="padding: 24px 0;">
|
|
175
|
+
<tr>
|
|
176
|
+
<td align="center">
|
|
177
|
+
<table width="560" cellpadding="0" cellspacing="0" style="max-width: 560px; background: #ffffff; border-radius: 8px; padding: 32px;">
|
|
178
|
+
${args.bodyHtml}
|
|
179
|
+
</table>
|
|
180
|
+
</td>
|
|
181
|
+
</tr>
|
|
182
|
+
</table>
|
|
183
|
+
</body>
|
|
184
|
+
</html>`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie auth-email-password, verschiedene Semantik
|
|
188
|
+
function renderButton(args: { url: string; label: string }): string {
|
|
189
|
+
return `<a href="${escapeHtmlAttr(args.url)}" style="display: inline-block; background: #1a1a1a; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;">${escapeHtml(args.label)}</a>`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie auth-email-password, verschiedene Semantik
|
|
193
|
+
function renderFallbackUrl(args: { url: string; label: string }): string {
|
|
194
|
+
return `<p style="margin: 24px 0 0; font-size: 12px; color: #666;">${escapeHtml(args.label)}<br /><a href="${escapeHtmlAttr(args.url)}" style="color: #1a1a1a; word-break: break-all;">${escapeHtml(args.url)}</a></p>`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ISO-Timestamp → "2026-05-04 13:45 UTC". Locale-unabhaengig + UTC-Suffix
|
|
198
|
+
// damit der User unabhaengig von seiner Tz sieht wann etwas passiert; bei
|
|
199
|
+
// un-parsbarem Input faellt's auf den raw-string zurueck.
|
|
200
|
+
function formatTimestamp(iso: string): string {
|
|
201
|
+
try {
|
|
202
|
+
const z = Temporal.Instant.from(iso).toZonedDateTimeISO("UTC");
|
|
203
|
+
return `${z.year}-${pad2(z.month)}-${pad2(z.day)} ${pad2(z.hour)}:${pad2(z.minute)} UTC`;
|
|
204
|
+
} catch {
|
|
205
|
+
return iso;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function pad2(n: number): string {
|
|
210
|
+
return String(n).padStart(2, "0");
|
|
211
|
+
}
|