@cosmicdrift/kumiko-bundled-features 0.98.0 → 0.102.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 +7 -6
- package/src/auth-email-password/auth-mailer.ts +28 -12
- package/src/auth-email-password/index.ts +3 -0
- package/src/channel-email/__tests__/smtp-transport-from-env.test.ts +35 -0
- package/src/channel-email/email-channel.ts +32 -20
- package/src/channel-email/feature.ts +2 -0
- package/src/channel-email/index.ts +6 -1
- package/src/channel-email/smtp-transport.ts +37 -0
- package/src/channel-in-app/feature.ts +1 -0
- package/src/channel-in-app/in-app-channel.ts +1 -0
- package/src/channel-push/feature.ts +1 -0
- package/src/channel-push/push-channel.ts +1 -0
- package/src/delivery/__tests__/delivery.integration.test.ts +247 -1
- package/src/delivery/attempt-log.ts +54 -0
- package/src/delivery/channel-context.ts +17 -0
- package/src/delivery/constants.ts +35 -1
- package/src/delivery/delivery-service.ts +118 -47
- package/src/delivery/events.ts +7 -1
- package/src/delivery/feature.ts +38 -14
- package/src/delivery/index.ts +3 -0
- package/src/delivery/jobs.ts +153 -0
- package/src/delivery/tables.ts +5 -1
- package/src/delivery/types.ts +25 -2
- package/src/file-foundation/feature.ts +28 -141
- package/src/presets/__tests__/dsgvo-self-service.test.ts +35 -0
- package/src/presets/dsgvo-self-service.ts +30 -0
- package/src/presets/index.ts +1 -0
- package/src/secrets/feature.ts +3 -1
- package/src/secrets/index.ts +1 -0
- package/src/tags/constants.ts +6 -0
- package/src/tags/index.ts +1 -0
- package/src/tags/web/__tests__/tag-filter.test.tsx +124 -0
- package/src/tags/web/__tests__/tags-cell.test.tsx +94 -0
- package/src/tags/web/client-plugin.tsx +6 -0
- package/src/tags/web/i18n.ts +2 -2
- package/src/tags/web/index.ts +2 -0
- package/src/tags/web/tag-filter.tsx +24 -12
- package/src/tags/web/tags-cell.tsx +46 -0
- package/src/text-content/__tests__/seed-legal-content.integration.test.ts +61 -0
- package/src/text-content/seeding.ts +35 -1
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +162 -0
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +7 -7
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +7 -13
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
// TagFilter — a drop-in tag filter for ANY entityList toolbar. Register it as
|
|
3
|
-
// the list's header slot (`screen.slots.header`)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// the list's header slot (`screen.slots.header`); the renderer passes it the
|
|
4
|
+
// list's screenId, and picking tags resolves the matching row ids and narrows
|
|
5
|
+
// the list via an id-set URL filter (`<screenId>.f.id=…`, applied as
|
|
6
|
+
// `{ field: "id", op: "in" }`). No host schema change: any list gets
|
|
7
|
+
// tag-filtering by mounting this in its header.
|
|
8
|
+
//
|
|
9
|
+
// The active selection is shown inline as colored chips next to the button, with
|
|
10
|
+
// a clear affordance — so the filter is visible, not hidden behind a count.
|
|
8
11
|
//
|
|
9
12
|
// ponytail: resolves ids from the assignment list (first 500 rows) + an id-IN
|
|
10
13
|
// URL filter — fine for typical tag volumes. For huge assignment sets add a
|
|
@@ -18,6 +21,7 @@ import {
|
|
|
18
21
|
} from "@cosmicdrift/kumiko-renderer";
|
|
19
22
|
import { type ReactNode, useState } from "react";
|
|
20
23
|
import { TagsQueries } from "../constants";
|
|
24
|
+
import { TagChip } from "./tag-chip";
|
|
21
25
|
import { TagPicker } from "./tag-picker";
|
|
22
26
|
|
|
23
27
|
// Tags chosen but zero matching entities → filter to a value that matches no row
|
|
@@ -29,6 +33,7 @@ type AssignmentRow = {
|
|
|
29
33
|
readonly entityType: string;
|
|
30
34
|
readonly entityId: string;
|
|
31
35
|
};
|
|
36
|
+
type TagRow = { readonly id: string; readonly name: string; readonly color?: string | null };
|
|
32
37
|
|
|
33
38
|
export function TagFilter({
|
|
34
39
|
entityName,
|
|
@@ -42,6 +47,7 @@ export function TagFilter({
|
|
|
42
47
|
const t = useTranslation();
|
|
43
48
|
const [open, setOpen] = useState(false);
|
|
44
49
|
const [selected, setSelected] = useState<readonly string[]>([]);
|
|
50
|
+
const catalog = useQuery<{ rows: readonly TagRow[] }>(TagsQueries.tagList, {});
|
|
45
51
|
const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(TagsQueries.assignmentList, {
|
|
46
52
|
limit: 500,
|
|
47
53
|
});
|
|
@@ -66,16 +72,22 @@ export function TagFilter({
|
|
|
66
72
|
urlState.setFilter("id", ids.length > 0 ? ids : [NO_MATCH]);
|
|
67
73
|
};
|
|
68
74
|
|
|
69
|
-
const
|
|
70
|
-
selected.length > 0
|
|
71
|
-
? t("tags.filter.active", { count: selected.length })
|
|
72
|
-
: t("tags.filter.label");
|
|
75
|
+
const byId = new Map((catalog.data?.rows ?? []).map((tg) => [tg.id, tg]));
|
|
73
76
|
|
|
74
77
|
return (
|
|
75
|
-
|
|
78
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
76
79
|
<Button variant="secondary" onClick={() => setOpen(true)} testId="tag-filter-open">
|
|
77
|
-
{label}
|
|
80
|
+
{t("tags.filter.label")}
|
|
78
81
|
</Button>
|
|
82
|
+
{selected.map((id) => {
|
|
83
|
+
const tag = byId.get(id);
|
|
84
|
+
return <TagChip key={id} name={tag?.name ?? id} color={tag?.color} />;
|
|
85
|
+
})}
|
|
86
|
+
{selected.length > 0 && (
|
|
87
|
+
<Button variant="secondary" onClick={() => applyFilter([])} testId="tag-filter-clear">
|
|
88
|
+
{t("tags.filter.clear")}
|
|
89
|
+
</Button>
|
|
90
|
+
)}
|
|
79
91
|
<TagPicker
|
|
80
92
|
entityType={entityName}
|
|
81
93
|
value={selected}
|
|
@@ -83,6 +95,6 @@ export function TagFilter({
|
|
|
83
95
|
open={open}
|
|
84
96
|
onOpenChange={setOpen}
|
|
85
97
|
/>
|
|
86
|
-
|
|
98
|
+
</div>
|
|
87
99
|
);
|
|
88
100
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// TagsCell — a reusable columnRenderer that shows an entity's tags as colored
|
|
3
|
+
// chips inline in a list row. Drop it into ANY entityList by declaring a labeled
|
|
4
|
+
// virtual column (no host-schema change), after mounting tagsClient():
|
|
5
|
+
// columns: [
|
|
6
|
+
// "title",
|
|
7
|
+
// { field: "tags", label: "Tags",
|
|
8
|
+
// renderer: { react: { __component: TAGS_COLUMN_RENDERER_NAME } } },
|
|
9
|
+
// ]
|
|
10
|
+
// It reads the row's id (entity ids are unique, so entityType is unnecessary)
|
|
11
|
+
// and renders a TagChip per assigned tag; renders nothing when the row has none.
|
|
12
|
+
//
|
|
13
|
+
// ponytail: bulk-loads the assignment list (first 500) + the catalog once and
|
|
14
|
+
// filters per row — kumiko's useQuery dedupes the identical (type, payload)
|
|
15
|
+
// calls, so the whole column shares two queries, not two-per-row. For huge
|
|
16
|
+
// assignment sets add a server-side entitiesByTag query + join filter.
|
|
17
|
+
|
|
18
|
+
import { type ColumnRendererProps, useQuery } from "@cosmicdrift/kumiko-renderer";
|
|
19
|
+
import type { ReactNode } from "react";
|
|
20
|
+
import { TagsQueries } from "../constants";
|
|
21
|
+
import { TagChip } from "./tag-chip";
|
|
22
|
+
|
|
23
|
+
type TagRow = { readonly id: string; readonly name: string; readonly color?: string | null };
|
|
24
|
+
type AssignmentRow = { readonly tagId: string; readonly entityId: string };
|
|
25
|
+
|
|
26
|
+
export function TagsCell({ row }: ColumnRendererProps): ReactNode {
|
|
27
|
+
const entityId = String(row["id"] ?? "");
|
|
28
|
+
const catalog = useQuery<{ rows: readonly TagRow[] }>(TagsQueries.tagList, {});
|
|
29
|
+
const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(TagsQueries.assignmentList, {
|
|
30
|
+
limit: 500,
|
|
31
|
+
});
|
|
32
|
+
if (entityId === "") return null;
|
|
33
|
+
const byId = new Map((catalog.data?.rows ?? []).map((t) => [t.id, t]));
|
|
34
|
+
const tagIds = (assignments.data?.rows ?? [])
|
|
35
|
+
.filter((a) => a.entityId === entityId)
|
|
36
|
+
.map((a) => a.tagId);
|
|
37
|
+
if (tagIds.length === 0) return null;
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex flex-wrap gap-1" data-testid="tags-cell">
|
|
40
|
+
{tagIds.map((id) => {
|
|
41
|
+
const tag = byId.get(id);
|
|
42
|
+
return <TagChip key={id} name={tag?.name ?? id} color={tag?.color} />;
|
|
43
|
+
})}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { type DbConnection, fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
|
+
import {
|
|
6
|
+
setupTestStack,
|
|
7
|
+
type TestStack,
|
|
8
|
+
unsafeCreateEntityTable,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
10
|
+
import { createTextContentFeature } from "../feature";
|
|
11
|
+
import { type LegalContentBlock, seedLegalContentFromJson } from "../seeding";
|
|
12
|
+
import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../table";
|
|
13
|
+
|
|
14
|
+
// Pins seedLegalContentFromJson: seeds into SYSTEM_TENANT_ID by default and
|
|
15
|
+
// re-seeds with ifExists:"update" so a changed template body lands on an
|
|
16
|
+
// already-seeded block (legal-drift guard — the load-bearing behaviour).
|
|
17
|
+
|
|
18
|
+
let stack: TestStack;
|
|
19
|
+
let db: DbConnection;
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
stack = await setupTestStack({ features: [createTextContentFeature()] });
|
|
23
|
+
db = stack.db;
|
|
24
|
+
await unsafeCreateEntityTable(db, textBlockEntity);
|
|
25
|
+
await createEventsTable(db);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterAll(async () => {
|
|
29
|
+
await stack.cleanup();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function read(slug: string, lang: string) {
|
|
33
|
+
return fetchOne<TextBlockRow>(db, textBlocksTable, { tenantId: SYSTEM_TENANT_ID, slug, lang });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("seedLegalContentFromJson", () => {
|
|
37
|
+
test("seeds all blocks into SYSTEM_TENANT_ID by default", async () => {
|
|
38
|
+
const blocks: LegalContentBlock[] = [
|
|
39
|
+
{ slug: "imprint", lang: "de", title: "Impressum", body: "Marc Frost" },
|
|
40
|
+
{ slug: "imprint", lang: "en", title: "Imprint", body: "Marc Frost" },
|
|
41
|
+
];
|
|
42
|
+
await seedLegalContentFromJson(db, blocks);
|
|
43
|
+
|
|
44
|
+
expect(await read("imprint", "de")).toMatchObject({ title: "Impressum", body: "Marc Frost" });
|
|
45
|
+
expect(await read("imprint", "en")).toMatchObject({ title: "Imprint", body: "Marc Frost" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("re-seed lifts an existing block to the new template state (ifExists:update)", async () => {
|
|
49
|
+
const v1: LegalContentBlock[] = [
|
|
50
|
+
{ slug: "privacy", lang: "de", title: "Datenschutz", body: "v1" },
|
|
51
|
+
];
|
|
52
|
+
await seedLegalContentFromJson(db, v1);
|
|
53
|
+
expect(await read("privacy", "de")).toMatchObject({ body: "v1" });
|
|
54
|
+
|
|
55
|
+
const v2: LegalContentBlock[] = [
|
|
56
|
+
{ slug: "privacy", lang: "de", title: "Datenschutz", body: "v2 + Sub-Processor-Tabelle" },
|
|
57
|
+
];
|
|
58
|
+
await seedLegalContentFromJson(db, v2);
|
|
59
|
+
expect(await read("privacy", "de")).toMatchObject({ body: "v2 + Sub-Processor-Tabelle" });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
createTenantDb,
|
|
10
10
|
type DbConnection,
|
|
11
11
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
12
|
-
import
|
|
12
|
+
import {
|
|
13
|
+
type SessionUser,
|
|
14
|
+
SYSTEM_TENANT_ID,
|
|
15
|
+
type TenantId,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
13
17
|
import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
|
|
14
18
|
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
15
19
|
import { type TextBlockRow, textBlockEntity, textBlocksTable } from "./table";
|
|
@@ -101,3 +105,33 @@ export async function seedTextBlock(
|
|
|
101
105
|
},
|
|
102
106
|
});
|
|
103
107
|
}
|
|
108
|
+
|
|
109
|
+
export type LegalContentBlock = {
|
|
110
|
+
readonly slug: string;
|
|
111
|
+
readonly lang: string;
|
|
112
|
+
readonly title: string;
|
|
113
|
+
readonly body: string;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Boot-Seed für Legal-/Marketing-Texte aus einer JSON-Vorlage. `ifExists:
|
|
117
|
+
// "update"` ist load-bearing: die Vorlage ist autoritativ, jeder Boot hebt
|
|
118
|
+
// bestehende Blöcke auf den kompilierten Stand — sonst erreichen gesetzlich
|
|
119
|
+
// vorgeschriebene Zusätze (z.B. Sub-Processor-Tabelle) bereits geseedete
|
|
120
|
+
// Prod-Records nie. Genau diese Entscheidung soll keine App selbst treffen.
|
|
121
|
+
export async function seedLegalContentFromJson(
|
|
122
|
+
db: DbConnection,
|
|
123
|
+
blocks: readonly LegalContentBlock[],
|
|
124
|
+
opts: { readonly tenantId?: TenantId } = {},
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const tenantId = opts.tenantId ?? SYSTEM_TENANT_ID;
|
|
127
|
+
for (const block of blocks) {
|
|
128
|
+
await seedTextBlock(db, {
|
|
129
|
+
tenantId,
|
|
130
|
+
slug: block.slug,
|
|
131
|
+
lang: block.lang,
|
|
132
|
+
title: block.title,
|
|
133
|
+
body: block.body,
|
|
134
|
+
ifExists: "update",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -80,7 +80,6 @@ beforeAll(async () => {
|
|
|
80
80
|
createUserDataRightsFeature(),
|
|
81
81
|
createUserDataRightsDefaultsFeature(),
|
|
82
82
|
],
|
|
83
|
-
files: { storageProvider: provider },
|
|
84
83
|
extraContext: ({ registry }) => ({
|
|
85
84
|
configResolver: resolver,
|
|
86
85
|
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
@@ -90,7 +90,6 @@ beforeAll(async () => {
|
|
|
90
90
|
createUserDataRightsFeature(),
|
|
91
91
|
createUserDataRightsDefaultsFeature(),
|
|
92
92
|
],
|
|
93
|
-
files: { storageProvider: flakyProvider },
|
|
94
93
|
extraContext: ({ registry }) => ({
|
|
95
94
|
configResolver: resolver,
|
|
96
95
|
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// #608 trap-closed regression: uploads and erasure resolve the file provider
|
|
2
|
+
// through ONE source (file-foundation), end-to-end through the real server.
|
|
3
|
+
//
|
|
4
|
+
// The stack mounts file-foundation + a provider plugin but does NOT inject a
|
|
5
|
+
// resolver (no `files:` option) — so buildServer must build the upload-route
|
|
6
|
+
// resolver itself from the mounted features, exactly as production does. A file
|
|
7
|
+
// uploaded through POST /api/files must land in the SAME store the GDPR forget
|
|
8
|
+
// pipeline deletes from. Before the unification the upload route wrote through a
|
|
9
|
+
// separately-wired provider while erasure deleted through file-foundation — so a
|
|
10
|
+
// regression to a static `storageProvider` makes the `provider.exists(...)`
|
|
11
|
+
// assertion below go red.
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
+
import { type SessionUser, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
18
|
+
import {
|
|
19
|
+
createInMemoryFileProvider,
|
|
20
|
+
type FileStorageProvider,
|
|
21
|
+
fileRefsTable,
|
|
22
|
+
type InMemoryFileProvider,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
24
|
+
import {
|
|
25
|
+
setupTestStack,
|
|
26
|
+
type TestStack,
|
|
27
|
+
unsafeCreateEntityTable,
|
|
28
|
+
unsafePushTables,
|
|
29
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
30
|
+
import {
|
|
31
|
+
buildMultipartBody,
|
|
32
|
+
patchFileInstanceofForBunTest,
|
|
33
|
+
resetTestTables,
|
|
34
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
35
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
36
|
+
import { createConfigFeature } from "../../config";
|
|
37
|
+
import { createConfigAccessorFactory } from "../../config/feature";
|
|
38
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
39
|
+
import { configValuesTable } from "../../config/table";
|
|
40
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
41
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
42
|
+
import { createFilesFeature } from "../../files";
|
|
43
|
+
import { createSessionsFeature } from "../../sessions";
|
|
44
|
+
import { createUserFeature, userEntity, userTable } from "../../user";
|
|
45
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
46
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
47
|
+
import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
|
|
48
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
49
|
+
import {
|
|
50
|
+
createForgetSeeders,
|
|
51
|
+
createTestFileProviderFeature,
|
|
52
|
+
type ForgetSeeders,
|
|
53
|
+
nowInstant,
|
|
54
|
+
READ_TENANT_MEMBERSHIPS_DDL,
|
|
55
|
+
TENANT_SYSTEM,
|
|
56
|
+
} from "./forget-test-helpers";
|
|
57
|
+
|
|
58
|
+
const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider";
|
|
59
|
+
|
|
60
|
+
let stack: TestStack;
|
|
61
|
+
let db: DbConnection;
|
|
62
|
+
let provider: InMemoryFileProvider;
|
|
63
|
+
let seed: ForgetSeeders;
|
|
64
|
+
let buildStorageProvider: (tenantId: string) => Promise<FileStorageProvider>;
|
|
65
|
+
|
|
66
|
+
function uuid(suffix: number): string {
|
|
67
|
+
return `cccccccc-cccc-4ccc-8ccc-${suffix.toString(16).padStart(12, "0")}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function uploadAs(user: SessionUser, fileName: string, bytes: Uint8Array): Promise<Response> {
|
|
71
|
+
const token = await stack.jwt.sign(user);
|
|
72
|
+
const fd = new FormData();
|
|
73
|
+
fd.append("file", new File([Buffer.from(bytes)], fileName, { type: "application/pdf" }));
|
|
74
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
75
|
+
return stack.app.request("/api/files", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
78
|
+
body,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
patchFileInstanceofForBunTest();
|
|
84
|
+
provider = createInMemoryFileProvider();
|
|
85
|
+
// Select the test plugin app-wide via a config app-override (no admin write).
|
|
86
|
+
const appOverrides = new Map<string, string>([[FILE_PROVIDER_CONFIG_KEY, "test"]]);
|
|
87
|
+
const resolver = createConfigResolver({ appOverrides });
|
|
88
|
+
stack = await setupTestStack({
|
|
89
|
+
features: [
|
|
90
|
+
createConfigFeature(),
|
|
91
|
+
createUserFeature(),
|
|
92
|
+
createFilesFeature(),
|
|
93
|
+
fileFoundationFeature,
|
|
94
|
+
createTestFileProviderFeature(provider, "test"),
|
|
95
|
+
createDataRetentionFeature(),
|
|
96
|
+
createComplianceProfilesFeature(),
|
|
97
|
+
createSessionsFeature(),
|
|
98
|
+
createUserDataRightsFeature(),
|
|
99
|
+
createUserDataRightsDefaultsFeature(),
|
|
100
|
+
],
|
|
101
|
+
// No `files:` option on purpose — buildServer must resolve the upload
|
|
102
|
+
// provider through the mounted file-foundation, like production.
|
|
103
|
+
extraContext: ({ registry }) => ({
|
|
104
|
+
configResolver: resolver,
|
|
105
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
db = stack.db;
|
|
109
|
+
seed = createForgetSeeders(db, provider);
|
|
110
|
+
buildStorageProvider = makeTenantStorageProviderResolver({
|
|
111
|
+
registry: stack.registry,
|
|
112
|
+
configResolver: resolver,
|
|
113
|
+
secrets: undefined,
|
|
114
|
+
db,
|
|
115
|
+
userId: SYSTEM_USER_ID,
|
|
116
|
+
handlerName: "test-608-e2e",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await unsafeCreateEntityTable(db, userEntity);
|
|
120
|
+
await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
|
|
121
|
+
await unsafePushTables(db, { fileRefsTable, configValuesTable });
|
|
122
|
+
await createEventsTable(db);
|
|
123
|
+
await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterAll(async () => {
|
|
127
|
+
await stack.cleanup();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
beforeEach(async () => {
|
|
131
|
+
provider.clear();
|
|
132
|
+
await resetTestTables(db, [userTable, "read_tenant_memberships", fileRefsTable]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("#608 unified file-storage :: route upload + GDPR erasure hit one store", () => {
|
|
136
|
+
test("a file uploaded via /api/files lands in the file-foundation store and forget erases it", async () => {
|
|
137
|
+
const userId = uuid(1);
|
|
138
|
+
// The uploader is also the forget subject (DeletionRequested + grace passed).
|
|
139
|
+
await seed.seedForgetUser(userId);
|
|
140
|
+
await seed.seedMembership(userId, TENANT_SYSTEM);
|
|
141
|
+
const uploader: SessionUser = { id: userId, tenantId: TENANT_SYSTEM, roles: ["Member"] };
|
|
142
|
+
|
|
143
|
+
const res = await uploadAs(uploader, "secret.pdf", new Uint8Array([10, 20, 30, 40]));
|
|
144
|
+
expect(res.status).toBe(201);
|
|
145
|
+
const { storageKey } = (await res.json()) as { storageKey: string };
|
|
146
|
+
|
|
147
|
+
// The upload route resolved its provider through file-foundation — the bytes
|
|
148
|
+
// are in `provider`. A static/separate upload provider would miss here.
|
|
149
|
+
expect(await provider.exists(storageKey)).toBe(true);
|
|
150
|
+
|
|
151
|
+
// Erasure resolves through the SAME source and deletes the very same bytes —
|
|
152
|
+
// no false "done", no orphaned binary.
|
|
153
|
+
const result = await runForgetCleanup({
|
|
154
|
+
db,
|
|
155
|
+
registry: stack.registry,
|
|
156
|
+
now: nowInstant(),
|
|
157
|
+
buildStorageProvider,
|
|
158
|
+
});
|
|
159
|
+
expect(result.processedUserIds).toContain(userId);
|
|
160
|
+
expect(await provider.exists(storageKey)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -51,12 +51,12 @@ function renderWith(ui: ReactElement, dispatcher: Dispatcher): ReturnType<typeof
|
|
|
51
51
|
return within(container);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
describe
|
|
54
|
+
// CI runs this file in its own `bun test` process (own ci.yml step), NOT in the
|
|
55
|
+
// shared `kumiko check` run — see bunfig.ci.toml pathIgnorePatterns. The shared
|
|
56
|
+
// single-process happy-dom corrupts React event delegation after ~30 prior DOM
|
|
57
|
+
// test files mount/unmount, so the click never reached the submit handler here
|
|
58
|
+
// (#457). A fresh process has no such accumulation.
|
|
59
|
+
describe("RequestAccountDeletionScreen", () => {
|
|
60
60
|
test("Submit → write(request-deletion-by-email) + enumeration-safe Success", async () => {
|
|
61
61
|
const calls: WriteCall[] = [];
|
|
62
62
|
const ui = renderWith(<RequestAccountDeletionScreen />, makeDispatcher(true, calls));
|
|
@@ -78,7 +78,7 @@ describe.skip("RequestAccountDeletionScreen", () => {
|
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
describe
|
|
81
|
+
describe("ConfirmAccountDeletionScreen", () => {
|
|
82
82
|
test("ohne ?token → missingToken, kein Confirm-Button", () => {
|
|
83
83
|
window.history.replaceState({}, "", "/delete-account/confirm");
|
|
84
84
|
const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(true, []));
|
|
@@ -114,19 +114,13 @@ async function waitForMount(view: ReturnType<typeof render>): Promise<void> {
|
|
|
114
114
|
});
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
// (sichtbar: alle Fails zeigen denselben active-state Screen statt der eigenen
|
|
125
|
-
// Test-Daten). Nicht aus diesem File fixbar — gleiche Architektur-Flake, wegen der
|
|
126
|
-
// `deletion-screens.test.tsx` im selben Verzeichnis quarantäniert ist. Un-skip,
|
|
127
|
-
// sobald das framework-weite Test-Isolation-Problem (#457) gelöst ist. Die
|
|
128
|
-
// QN-Drift-Pins + formatDate unten decken die CI-stabile Verdrahtungs-Korrektheit ab.
|
|
129
|
-
describe.skip("PrivacyCenterScreen", () => {
|
|
117
|
+
// CI runs this file in its own `bun test` process (own ci.yml step), NOT in the
|
|
118
|
+
// shared `kumiko check` run — see bunfig.ci.toml pathIgnorePatterns. In the shared
|
|
119
|
+
// single-process happy-dom, the global `afterEach` from `test-setup/dom.preload.ts`
|
|
120
|
+
// plus accumulated global DOM/event state across ~30 prior DOM test files corrupts
|
|
121
|
+
// these in-flight renders (#457-class). A fresh process has no such accumulation.
|
|
122
|
+
// The QN-Drift-Pins + formatDate describes below are pure-logic and CI-stable.
|
|
123
|
+
describe("PrivacyCenterScreen", () => {
|
|
130
124
|
test("aktiver User: Export/Einschränken/Löschen-Sektionen, Texte übersetzt (keine rohen Keys)", async () => {
|
|
131
125
|
const { view } = renderCenter({ me: activeMe });
|
|
132
126
|
await waitForMount(view);
|