@cosmicdrift/kumiko-bundled-features 0.88.0 → 0.90.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 +9 -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/folders/__tests__/drift.test.ts +43 -0
- package/src/folders/__tests__/feature.test.ts +168 -0
- package/src/folders/__tests__/folders.integration.test.ts +290 -0
- package/src/folders/aggregate-id.ts +23 -0
- package/src/folders/constants.ts +40 -0
- package/src/folders/entity.ts +42 -0
- package/src/folders/executor.ts +11 -0
- package/src/folders/feature.ts +106 -0
- package/src/folders/handlers/clear-folder.write.ts +35 -0
- package/src/folders/handlers/set-folder.write.ts +82 -0
- package/src/folders/index.ts +23 -0
- package/src/folders/schemas.ts +18 -0
- package/src/folders/web/__tests__/folder-section.test.tsx +181 -0
- package/src/folders/web/__tests__/tree.test.ts +58 -0
- package/src/folders/web/client-plugin.tsx +16 -0
- package/src/folders/web/folder-manager.tsx +323 -0
- package/src/folders/web/folder-section.tsx +198 -0
- package/src/folders/web/i18n.ts +55 -0
- package/src/folders/web/index.ts +6 -0
- package/src/folders/web/tree.ts +54 -0
- package/src/folders-user-data/hooks.ts +58 -0
- package/src/folders-user-data/index.ts +33 -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__/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 +96 -21
- package/src/user-data-rights/handlers/deletion-grace-period.ts +17 -5
- 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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Retention-Cleanup-Runner (S2.D2b) — pure Function, vom retention-cleanup-Cron
|
|
2
|
+
// pro fan-out-Tenant aufgerufen.
|
|
3
|
+
//
|
|
4
|
+
// Iteriert alle implicit-Entity-Projektionen, loest pro Entity die effektive
|
|
5
|
+
// Retention-Policy (3-Schicht-Resolver, siehe resolver.ts) und wendet die
|
|
6
|
+
// Strategy auf Rows an deren reference-Timestamp aelter als der keepFor-Cutoff
|
|
7
|
+
// ist:
|
|
8
|
+
//
|
|
9
|
+
// - hardDelete → deleteManyBatched (selbst-begrenzt, kein Full-Table-Scan)
|
|
10
|
+
// - softDelete → isDeleted=true/deletedAt=now, nur auf noch-nicht-geloeschte
|
|
11
|
+
// - anonymize → DEFERRED (siehe unten)
|
|
12
|
+
// - blockDelete → ignoriert (Aufbewahrungs-Pflicht; user-forget loest anonymize)
|
|
13
|
+
//
|
|
14
|
+
// **Schaerfer als soft-delete-cleanup:** dieser Cron hardDeleted LIVE Rows
|
|
15
|
+
// (keyed auf reference, Default createdAt), nicht bereits-soft-geloeschte.
|
|
16
|
+
// Darum die Spalten-Existenz-Pruefung vor jedem WHERE — eine fehlende/vertippte
|
|
17
|
+
// reference-Spalte wuerde sonst ein malformed/all-matching WHERE ergeben und
|
|
18
|
+
// pauschal loeschen.
|
|
19
|
+
//
|
|
20
|
+
// **anonymize deferred:** anonymize behaelt die Row. Ohne Idempotenz-Marker
|
|
21
|
+
// wuerde der taegliche UPDATE jede past-cutoff Row endlos neu treffen — genau
|
|
22
|
+
// der Full-Table-Scan den die Strategie vermeiden soll (hardDelete begrenzt
|
|
23
|
+
// sich selbst, softDelete via isDeleted:false). Kein bundled-Entity nutzt
|
|
24
|
+
// zeitgesteuertes anonymize; der user-forget-Flow (run-forget-cleanup) deckt
|
|
25
|
+
// anonymize keyed auf userId ab. Follow-up fuer den Marker.
|
|
26
|
+
|
|
27
|
+
import { deleteManyBatched, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
28
|
+
import type { DbRunner, WhereObject } from "@cosmicdrift/kumiko-framework/db";
|
|
29
|
+
import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
30
|
+
import { computeCutoff, type Instant } from "./keep-for";
|
|
31
|
+
import type { RetentionPresetKey } from "./presets";
|
|
32
|
+
import { resolveRetentionPolicyForTenant } from "./resolve-for-tenant";
|
|
33
|
+
|
|
34
|
+
const DEFAULT_BATCH_LIMIT = 1000;
|
|
35
|
+
const DEFAULT_REFERENCE_FIELD = "createdAt";
|
|
36
|
+
|
|
37
|
+
// Der Boot-Validator (boot-validator/pii-retention.ts FRAMEWORK_TIMESTAMP_FIELDS)
|
|
38
|
+
// erlaubt diese Aliase als retention.reference, auch wenn sie nicht in
|
|
39
|
+
// entity.fields deklariert sind — die physischen Spalten heissen aber anders
|
|
40
|
+
// (table-builder.ts: inserted_at/modified_at). Hier zur Cleanup-Zeit auf das
|
|
41
|
+
// echte Entity-Feld mappen, sonst trifft die Spalten-Existenz-Pruefung unten
|
|
42
|
+
// und der Cron wuerde lautlos nichts tun. deletedAt/lastSeenAt sind echte
|
|
43
|
+
// Felder (softDelete bzw. session) und brauchen keine Uebersetzung.
|
|
44
|
+
const FRAMEWORK_REFERENCE_ALIAS: Readonly<Record<string, string>> = {
|
|
45
|
+
createdAt: "insertedAt",
|
|
46
|
+
updatedAt: "modifiedAt",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export interface RunRetentionCleanupArgs {
|
|
50
|
+
readonly db: DbRunner;
|
|
51
|
+
readonly registry: Registry;
|
|
52
|
+
readonly tenantId: TenantId;
|
|
53
|
+
/** Layer-2 Preset (aus resolveTenantRetentionPreset). null = nur Layer 1/3. */
|
|
54
|
+
readonly tenantPreset: RetentionPresetKey | null;
|
|
55
|
+
/** Now-Injection — Tests pinnen den Wert ohne Date-Mock (Pattern keep-for.ts). */
|
|
56
|
+
readonly now: Instant;
|
|
57
|
+
readonly batchLimit?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RetentionCleanupSkip {
|
|
61
|
+
readonly entityName: string;
|
|
62
|
+
readonly reason: "missing_reference_column" | "missing_softdelete_columns";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RunRetentionCleanupResult {
|
|
66
|
+
readonly hardDeleted: number;
|
|
67
|
+
readonly softDeleted: number;
|
|
68
|
+
/** Entities mit anonymize-Strategy — deferred (Header). Cron logt sie. */
|
|
69
|
+
readonly anonymizeDeferred: readonly string[];
|
|
70
|
+
/** Anomalien: Policy referenziert eine Spalte die die Tabelle nicht hat. */
|
|
71
|
+
readonly skipped: readonly RetentionCleanupSkip[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runRetentionCleanup(
|
|
75
|
+
args: RunRetentionCleanupArgs,
|
|
76
|
+
): Promise<RunRetentionCleanupResult> {
|
|
77
|
+
const { db, registry, tenantId, tenantPreset, now } = args;
|
|
78
|
+
const batchLimit = args.batchLimit ?? DEFAULT_BATCH_LIMIT;
|
|
79
|
+
|
|
80
|
+
let hardDeleted = 0;
|
|
81
|
+
let softDeleted = 0;
|
|
82
|
+
const anonymizeDeferred: string[] = [];
|
|
83
|
+
const skipped: RetentionCleanupSkip[] = [];
|
|
84
|
+
|
|
85
|
+
for (const proj of registry.getAllProjections().values()) {
|
|
86
|
+
// Nur implicit-Entity-Projektionen mit Tabelle — wie soft-delete-cleanup.
|
|
87
|
+
// Custom-Projektionen + unmanaged-Tables (z.B. sessions) sind kein Target.
|
|
88
|
+
if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table) continue;
|
|
89
|
+
const entityName = proj.source;
|
|
90
|
+
|
|
91
|
+
const resolved = await resolveRetentionPolicyForTenant({
|
|
92
|
+
db,
|
|
93
|
+
registry,
|
|
94
|
+
tenantId,
|
|
95
|
+
entityName,
|
|
96
|
+
tenantPreset,
|
|
97
|
+
});
|
|
98
|
+
const policy = resolved.policy;
|
|
99
|
+
if (!policy) continue;
|
|
100
|
+
|
|
101
|
+
const table = proj.table as Record<string, unknown>; // @cast-boundary column-presence probe
|
|
102
|
+
const declaredReference = policy.reference ?? DEFAULT_REFERENCE_FIELD;
|
|
103
|
+
const referenceField = FRAMEWORK_REFERENCE_ALIAS[declaredReference] ?? declaredReference;
|
|
104
|
+
|
|
105
|
+
if (table[referenceField] === undefined) {
|
|
106
|
+
skipped.push({ entityName, reason: "missing_reference_column" });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cutoff = computeCutoff(policy.keepFor, now);
|
|
111
|
+
const where: WhereObject = { [referenceField]: { lt: cutoff } };
|
|
112
|
+
// Tenant-Scope nur wenn die Tabelle eine tenantId-Spalte hat — identisch zu
|
|
113
|
+
// soft-delete-cleanup. Ohne diesen Filter wuerde ein Tenant die Rows eines
|
|
114
|
+
// anderen treffen.
|
|
115
|
+
if (table["tenantId"] !== undefined) {
|
|
116
|
+
where["tenantId"] = tenantId;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
switch (policy.strategy) {
|
|
120
|
+
case "hardDelete": {
|
|
121
|
+
const res = await deleteManyBatched(db, proj.table, where, { limit: batchLimit });
|
|
122
|
+
hardDeleted += res.deleted;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "softDelete": {
|
|
126
|
+
if (table["isDeleted"] === undefined || table["deletedAt"] === undefined) {
|
|
127
|
+
skipped.push({ entityName, reason: "missing_softdelete_columns" });
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
const updated = await updateMany(
|
|
131
|
+
db,
|
|
132
|
+
proj.table,
|
|
133
|
+
{ isDeleted: true, deletedAt: now },
|
|
134
|
+
{ ...where, isDeleted: false },
|
|
135
|
+
);
|
|
136
|
+
softDeleted += updated.length;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case "anonymize": {
|
|
140
|
+
anonymizeDeferred.push(entityName);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "blockDelete": {
|
|
144
|
+
// skip: Aufbewahrungs-Pflicht — Cleanup ignoriert, user-forget anonymisiert
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { hardDeleted, softDeleted, anonymizeDeferred, skipped };
|
|
151
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { folderAssignmentAggregateId } from "../aggregate-id";
|
|
3
|
+
|
|
4
|
+
// Drift-Pin-Tests — these values are cross-boot contracts. If they go red:
|
|
5
|
+
// stop, think, revert. aggregate-id.ts names this file.
|
|
6
|
+
|
|
7
|
+
const TENANT = "00000000-0000-0000-0000-000000000001";
|
|
8
|
+
|
|
9
|
+
describe("folders drift pins", () => {
|
|
10
|
+
test("folder-assignment aggregate-id namespace is stable across boots", () => {
|
|
11
|
+
// FOLDER_ASSIGNMENT_NAMESPACE is in stone — changing it re-keys every
|
|
12
|
+
// existing assignment stream and breaks event-replay. If this fails: revert
|
|
13
|
+
// the namespace, do not adjust the expected values.
|
|
14
|
+
const base = folderAssignmentAggregateId(TENANT, "credit", "c-1");
|
|
15
|
+
|
|
16
|
+
expect(base).toBe(folderAssignmentAggregateId(TENANT, "credit", "c-1")); // deterministic
|
|
17
|
+
// folderId is NOT part of the key (single-membership): every OTHER tuple
|
|
18
|
+
// component is, with no collisions across the axes.
|
|
19
|
+
expect(base).not.toBe(
|
|
20
|
+
folderAssignmentAggregateId("11111111-1111-1111-1111-111111111111", "credit", "c-1"),
|
|
21
|
+
);
|
|
22
|
+
expect(base).not.toBe(folderAssignmentAggregateId(TENANT, "invoice", "c-1"));
|
|
23
|
+
expect(base).not.toBe(folderAssignmentAggregateId(TENANT, "credit", "c-2"));
|
|
24
|
+
|
|
25
|
+
// Pinned actual outputs — the drift-detector for the namespace constant.
|
|
26
|
+
expect(base).toBe("a57e2be6-1831-5d08-9e83-2de64578de6d");
|
|
27
|
+
expect(
|
|
28
|
+
folderAssignmentAggregateId("11111111-1111-1111-1111-111111111111", "credit", "c-1"),
|
|
29
|
+
).toBe("d2153ce9-5ffa-544e-b850-a4a7fefa7d89");
|
|
30
|
+
expect(folderAssignmentAggregateId(TENANT, "invoice", "c-1")).toBe(
|
|
31
|
+
"7c849492-a806-5e73-a824-e90dfc761e3a",
|
|
32
|
+
);
|
|
33
|
+
expect(folderAssignmentAggregateId(TENANT, "credit", "c-2")).toBe(
|
|
34
|
+
"d9ad71bc-78db-5588-9f61-b77f35229ed3",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("aggregate-id format is a valid uuid", () => {
|
|
39
|
+
expect(folderAssignmentAggregateId(TENANT, "credit", "c-1")).toMatch(
|
|
40
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { DEFAULT_FOLDER_ROLES } from "../constants";
|
|
3
|
+
import { createFoldersFeature } from "../feature";
|
|
4
|
+
import { clearFolderPayloadSchema, setFolderPayloadSchema } from "../schemas";
|
|
5
|
+
|
|
6
|
+
// Unit tests: feature-shape, role-options, schema-validation. The ES-loop
|
|
7
|
+
// behaviour (single-membership set/move/clear, projection, tenant-isolation,
|
|
8
|
+
// read composition) needs a real stack → folders.integration.test.ts.
|
|
9
|
+
|
|
10
|
+
function writeAccess(
|
|
11
|
+
feature: ReturnType<typeof createFoldersFeature>,
|
|
12
|
+
nameMatch: string,
|
|
13
|
+
): readonly string[] {
|
|
14
|
+
const entry = Object.entries(feature.writeHandlers).find(([qn]) => qn.includes(nameMatch));
|
|
15
|
+
if (!entry) throw new Error(`handler ${nameMatch} not registered`);
|
|
16
|
+
const access = entry[1].access;
|
|
17
|
+
if (!access || !("roles" in access)) throw new Error(`handler ${nameMatch} has no roles`);
|
|
18
|
+
return access.roles;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function queryAccess(
|
|
22
|
+
feature: ReturnType<typeof createFoldersFeature>,
|
|
23
|
+
nameMatch: string,
|
|
24
|
+
): readonly string[] {
|
|
25
|
+
const entry = Object.entries(feature.queryHandlers).find(([qn]) => qn.includes(nameMatch));
|
|
26
|
+
if (!entry) throw new Error(`query ${nameMatch} not registered`);
|
|
27
|
+
const access = entry[1].access;
|
|
28
|
+
if (!access || !("roles" in access)) throw new Error(`query ${nameMatch} has no roles`);
|
|
29
|
+
return access.roles;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rawWriteAccess(
|
|
33
|
+
feature: ReturnType<typeof createFoldersFeature>,
|
|
34
|
+
nameMatch: string,
|
|
35
|
+
): unknown {
|
|
36
|
+
const entry = Object.entries(feature.writeHandlers).find(([qn]) => qn.includes(nameMatch));
|
|
37
|
+
if (!entry) throw new Error(`handler ${nameMatch} not registered`);
|
|
38
|
+
return entry[1].access;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("createFoldersFeature shape", () => {
|
|
42
|
+
test("registers folder + folder-assignment entities, 5 write-handlers, 3 query-handlers", () => {
|
|
43
|
+
const feature = createFoldersFeature();
|
|
44
|
+
|
|
45
|
+
expect(Object.keys(feature.entities ?? {})).toEqual(
|
|
46
|
+
expect.arrayContaining(["folder", "folder-assignment"]),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(Object.keys(feature.writeHandlers)).toEqual(
|
|
50
|
+
expect.arrayContaining([
|
|
51
|
+
expect.stringMatching(/folder:create/),
|
|
52
|
+
expect.stringMatching(/folder:update/),
|
|
53
|
+
expect.stringMatching(/folder:delete/),
|
|
54
|
+
expect.stringMatching(/set-folder/),
|
|
55
|
+
expect.stringMatching(/clear-folder/),
|
|
56
|
+
]),
|
|
57
|
+
);
|
|
58
|
+
expect(Object.keys(feature.writeHandlers)).toHaveLength(5);
|
|
59
|
+
|
|
60
|
+
expect(Object.keys(feature.queryHandlers)).toEqual(
|
|
61
|
+
expect.arrayContaining([
|
|
62
|
+
expect.stringMatching(/folder:list/),
|
|
63
|
+
expect.stringMatching(/folder:detail/),
|
|
64
|
+
expect.stringMatching(/folder-assignment:list/),
|
|
65
|
+
]),
|
|
66
|
+
);
|
|
67
|
+
expect(Object.keys(feature.queryHandlers)).toHaveLength(3);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("createFoldersFeature access-options", () => {
|
|
72
|
+
test("without options: singleton with default roles on every path", () => {
|
|
73
|
+
const feature = createFoldersFeature();
|
|
74
|
+
expect(feature).toBe(createFoldersFeature());
|
|
75
|
+
for (const path of [
|
|
76
|
+
"folder:create",
|
|
77
|
+
"folder:update",
|
|
78
|
+
"folder:delete",
|
|
79
|
+
"set-folder",
|
|
80
|
+
"clear-folder",
|
|
81
|
+
]) {
|
|
82
|
+
expect(writeAccess(feature, path)).toEqual([...DEFAULT_FOLDER_ROLES]);
|
|
83
|
+
}
|
|
84
|
+
expect(queryAccess(feature, "folder:list")).toEqual([...DEFAULT_FOLDER_ROLES]);
|
|
85
|
+
expect(queryAccess(feature, "folder-assignment:list")).toEqual([...DEFAULT_FOLDER_ROLES]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("roles option overrides every write- and query-path", () => {
|
|
89
|
+
const feature = createFoldersFeature({ roles: ["Admin", "Editor"] });
|
|
90
|
+
expect(writeAccess(feature, "set-folder")).toEqual(["Admin", "Editor"]);
|
|
91
|
+
expect(writeAccess(feature, "folder:create")).toEqual(["Admin", "Editor"]);
|
|
92
|
+
expect(queryAccess(feature, "folder:list")).toEqual(["Admin", "Editor"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("access:{openToAll} applies to every write- and query-path", () => {
|
|
96
|
+
const feature = createFoldersFeature({ access: { openToAll: true } });
|
|
97
|
+
for (const path of [
|
|
98
|
+
"folder:create",
|
|
99
|
+
"folder:update",
|
|
100
|
+
"folder:delete",
|
|
101
|
+
"set-folder",
|
|
102
|
+
"clear-folder",
|
|
103
|
+
]) {
|
|
104
|
+
expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("access takes precedence over the roles shorthand", () => {
|
|
109
|
+
const feature = createFoldersFeature({ access: { openToAll: true }, roles: ["Admin"] });
|
|
110
|
+
expect(rawWriteAccess(feature, "set-folder")).toEqual({ openToAll: true });
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("createFoldersFeature toggleable-option (tier-gating)", () => {
|
|
115
|
+
test("without toggleable: feature is always-on (toggleableDefault undefined)", () => {
|
|
116
|
+
expect(createFoldersFeature().toggleableDefault).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("toggleable:{default:false} makes the feature tier-gatable, fail-closed", () => {
|
|
120
|
+
const feature = createFoldersFeature({
|
|
121
|
+
access: { openToAll: true },
|
|
122
|
+
toggleable: { default: false },
|
|
123
|
+
});
|
|
124
|
+
expect(feature.toggleableDefault).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("toggleable alone (no access/roles) builds a fresh, non-singleton feature", () => {
|
|
128
|
+
const feature = createFoldersFeature({ toggleable: { default: false } });
|
|
129
|
+
expect(feature).not.toBe(createFoldersFeature());
|
|
130
|
+
expect(writeAccess(feature, "folder:create")).toEqual([...DEFAULT_FOLDER_ROLES]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("setFolderPayloadSchema", () => {
|
|
135
|
+
const valid = { folderId: "f-1", entityType: "credit", entityId: "c-1" };
|
|
136
|
+
|
|
137
|
+
test("accepts a full (folder, entity) reference", () => {
|
|
138
|
+
expect(setFolderPayloadSchema.safeParse(valid).success).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("rejects missing folderId", () => {
|
|
142
|
+
expect(
|
|
143
|
+
setFolderPayloadSchema.safeParse({ entityType: "credit", entityId: "c-1" }).success,
|
|
144
|
+
).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("rejects empty folderId", () => {
|
|
148
|
+
expect(setFolderPayloadSchema.safeParse({ ...valid, folderId: "" }).success).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("rejects entityId over 128 chars", () => {
|
|
152
|
+
expect(setFolderPayloadSchema.safeParse({ ...valid, entityId: "x".repeat(129) }).success).toBe(
|
|
153
|
+
false,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("clearFolderPayloadSchema", () => {
|
|
159
|
+
test("accepts an entity reference (no folderId)", () => {
|
|
160
|
+
expect(
|
|
161
|
+
clearFolderPayloadSchema.safeParse({ entityType: "credit", entityId: "c-1" }).success,
|
|
162
|
+
).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("rejects missing entityId", () => {
|
|
166
|
+
expect(clearFolderPayloadSchema.safeParse({ entityType: "credit" }).success).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Full-stack integration for the folders bundle. Drives create → set → list →
|
|
2
|
+
// move → clear through the real dispatcher + entity-projection + DB. Proves the
|
|
3
|
+
// architecture end-to-end WITHOUT any host wiring (folders are host-agnostic —
|
|
4
|
+
// the host is just the entityType/entityId strings on the assignment):
|
|
5
|
+
// - create-folder projects into read_folders (incl. nested parentId)
|
|
6
|
+
// - set-folder projects a SINGLE membership row keyed by (entityType, entityId)
|
|
7
|
+
// - re-setting to a different folder MOVES the entity (still one row) — the
|
|
8
|
+
// defining difference from tags' many-to-many
|
|
9
|
+
// - clear-folder unfiles; set → clear → set resurrects the deterministic stream
|
|
10
|
+
// - referential integrity (unknown folderId rejected) + multi-tenant isolation
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
|
+
import {
|
|
16
|
+
createTestUser,
|
|
17
|
+
setupTestStack,
|
|
18
|
+
type TestStack,
|
|
19
|
+
unsafeCreateEntityTable,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
import { FoldersHandlers, FoldersQueries } from "../constants";
|
|
22
|
+
import { folderAssignmentEntity, folderEntity } from "../entity";
|
|
23
|
+
import { createFoldersFeature } from "../feature";
|
|
24
|
+
|
|
25
|
+
const foldersFeature = createFoldersFeature();
|
|
26
|
+
|
|
27
|
+
let stack: TestStack;
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
stack = await setupTestStack({ features: [foldersFeature] });
|
|
31
|
+
await unsafeCreateEntityTable(stack.db, folderEntity);
|
|
32
|
+
await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
|
|
33
|
+
await createEventsTable(stack.db);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
await stack.cleanup();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
42
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_folders");
|
|
43
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_folder_assignments");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
47
|
+
const otherTenant = createTestUser({
|
|
48
|
+
roles: ["TenantAdmin"],
|
|
49
|
+
tenantId: "00000000-0000-4000-8000-0000000000aa",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
async function createFolder(name: string, parentId?: string, user = admin): Promise<string> {
|
|
53
|
+
const payload = parentId === undefined ? { name } : { name, parentId };
|
|
54
|
+
const folder = await stack.http.writeOk<{ id: string }>(
|
|
55
|
+
FoldersHandlers.createFolder,
|
|
56
|
+
payload,
|
|
57
|
+
user,
|
|
58
|
+
);
|
|
59
|
+
return folder.id;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function setFolder(folderId: string, entityId: string, user = admin) {
|
|
63
|
+
return stack.http.writeOk(
|
|
64
|
+
FoldersHandlers.setFolder,
|
|
65
|
+
{ folderId, entityType: "credit", entityId },
|
|
66
|
+
user,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function clearFolder(entityId: string, user = admin) {
|
|
71
|
+
return stack.http.writeOk(FoldersHandlers.clearFolder, { entityType: "credit", entityId }, user);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function listFolders(user = admin): Promise<Array<Record<string, unknown>>> {
|
|
75
|
+
const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
76
|
+
FoldersQueries.folderList,
|
|
77
|
+
{},
|
|
78
|
+
user,
|
|
79
|
+
);
|
|
80
|
+
return res.rows;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function assignmentsOf(
|
|
84
|
+
entityId: string,
|
|
85
|
+
user = admin,
|
|
86
|
+
): Promise<Array<Record<string, unknown>>> {
|
|
87
|
+
const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
88
|
+
FoldersQueries.assignmentList,
|
|
89
|
+
{ filter: { field: "entityId", op: "eq", value: entityId } },
|
|
90
|
+
user,
|
|
91
|
+
);
|
|
92
|
+
return res.rows;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Active assignments only — clear soft-deletes (the stream is kept so a re-set
|
|
96
|
+
// can restore it), so isDeleted=true rows must not count as filed.
|
|
97
|
+
async function countAssignments(tenantId: string): Promise<number> {
|
|
98
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
99
|
+
"SELECT count(*)::int AS n FROM read_folder_assignments WHERE tenant_id = $1 AND is_deleted = FALSE",
|
|
100
|
+
[tenantId],
|
|
101
|
+
);
|
|
102
|
+
return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe("folders integration — catalog (tree)", () => {
|
|
106
|
+
test("create-folder lands in read_folders", async () => {
|
|
107
|
+
const id = await createFolder("Immobilie Berlin");
|
|
108
|
+
const folders = await listFolders();
|
|
109
|
+
expect(folders).toHaveLength(1);
|
|
110
|
+
expect(folders[0]?.["id"]).toBe(id);
|
|
111
|
+
expect(folders[0]?.["name"]).toBe("Immobilie Berlin");
|
|
112
|
+
expect(folders[0]?.["parentId"]).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("create-folder with parentId nests under the parent", async () => {
|
|
116
|
+
const root = await createFolder("Immobilie Berlin");
|
|
117
|
+
const child = await createFolder("Person Müller", root);
|
|
118
|
+
const folders = await listFolders();
|
|
119
|
+
expect(folders).toHaveLength(2);
|
|
120
|
+
const childRow = folders.find((f) => f["id"] === child);
|
|
121
|
+
expect(childRow?.["parentId"]).toBe(root);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("folders integration — single-membership set / move / clear", () => {
|
|
126
|
+
test("set-folder files an entity; assignment is queryable", async () => {
|
|
127
|
+
const f = await createFolder("Gruppe A");
|
|
128
|
+
await setFolder(f, "credit-1");
|
|
129
|
+
|
|
130
|
+
const rows = await assignmentsOf("credit-1");
|
|
131
|
+
expect(rows).toHaveLength(1);
|
|
132
|
+
expect(rows[0]?.["folderId"]).toBe(f);
|
|
133
|
+
expect(rows[0]?.["entityType"]).toBe("credit");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("re-setting to a different folder MOVES (still exactly one row)", async () => {
|
|
137
|
+
const a = await createFolder("A");
|
|
138
|
+
const b = await createFolder("B");
|
|
139
|
+
await setFolder(a, "credit-1");
|
|
140
|
+
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
141
|
+
|
|
142
|
+
await setFolder(b, "credit-1"); // MOVE, not a second assignment
|
|
143
|
+
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
144
|
+
const rows = await assignmentsOf("credit-1");
|
|
145
|
+
expect(rows).toHaveLength(1);
|
|
146
|
+
expect(rows[0]?.["folderId"]).toBe(b);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("set the same folder twice is an idempotent no-op (one row)", async () => {
|
|
150
|
+
const f = await createFolder("dup");
|
|
151
|
+
await setFolder(f, "credit-2");
|
|
152
|
+
await setFolder(f, "credit-2");
|
|
153
|
+
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("clear-folder unfiles the entity", async () => {
|
|
157
|
+
const f = await createFolder("temp");
|
|
158
|
+
await setFolder(f, "credit-3");
|
|
159
|
+
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
160
|
+
|
|
161
|
+
await clearFolder("credit-3");
|
|
162
|
+
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
163
|
+
expect(await assignmentsOf("credit-3")).toHaveLength(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("clearing a never-filed entity succeeds (idempotent end-state)", async () => {
|
|
167
|
+
await clearFolder("credit-never");
|
|
168
|
+
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("set → clear → set resurrects the same deterministic stream", async () => {
|
|
172
|
+
const f = await createFolder("recurring");
|
|
173
|
+
await setFolder(f, "credit-r");
|
|
174
|
+
await clearFolder("credit-r");
|
|
175
|
+
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
176
|
+
|
|
177
|
+
await setFolder(f, "credit-r"); // restore, not 409
|
|
178
|
+
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
179
|
+
expect((await assignmentsOf("credit-r"))[0]?.["folderId"]).toBe(f);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("set → clear → set into a DIFFERENT folder lands in the new folder", async () => {
|
|
183
|
+
const a = await createFolder("A");
|
|
184
|
+
const b = await createFolder("B");
|
|
185
|
+
await setFolder(a, "credit-x");
|
|
186
|
+
await clearFolder("credit-x");
|
|
187
|
+
await setFolder(b, "credit-x"); // restore + update folderId to b
|
|
188
|
+
const rows = await assignmentsOf("credit-x");
|
|
189
|
+
expect(rows).toHaveLength(1);
|
|
190
|
+
expect(rows[0]?.["folderId"]).toBe(b);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("folders integration — rename via folder:update", () => {
|
|
195
|
+
test("update renames, optimistic-locked, bumps version", async () => {
|
|
196
|
+
const id = await createFolder("Alt");
|
|
197
|
+
const before = (await listFolders()).find((f) => f["id"] === id);
|
|
198
|
+
const version = before?.["version"] as number;
|
|
199
|
+
expect(typeof version).toBe("number");
|
|
200
|
+
|
|
201
|
+
await stack.http.writeOk(
|
|
202
|
+
FoldersHandlers.updateFolder,
|
|
203
|
+
{ id, version, changes: { name: "Neu" } },
|
|
204
|
+
admin,
|
|
205
|
+
);
|
|
206
|
+
const after = (await listFolders()).find((f) => f["id"] === id);
|
|
207
|
+
expect(after?.["name"]).toBe("Neu");
|
|
208
|
+
expect(after?.["version"]).toBe(version + 1);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("folders integration — referential integrity", () => {
|
|
213
|
+
test("set-folder with an unknown folderId is rejected (no dangling assignment)", async () => {
|
|
214
|
+
const err = await stack.http.writeErr(
|
|
215
|
+
FoldersHandlers.setFolder,
|
|
216
|
+
{ folderId: "00000000-0000-4000-8000-00000000dead", entityType: "credit", entityId: "c-y" },
|
|
217
|
+
admin,
|
|
218
|
+
);
|
|
219
|
+
expect(err.httpStatus).toBe(404);
|
|
220
|
+
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("folders integration — multi-tenant isolation", () => {
|
|
225
|
+
test("tenant B sees neither tenant A's folders nor assignments", async () => {
|
|
226
|
+
const f = await createFolder("A-only", undefined, admin);
|
|
227
|
+
await setFolder(f, "credit-8", admin);
|
|
228
|
+
|
|
229
|
+
expect(await listFolders(otherTenant)).toHaveLength(0);
|
|
230
|
+
expect(await assignmentsOf("credit-8", otherTenant)).toHaveLength(0);
|
|
231
|
+
|
|
232
|
+
expect(await listFolders(admin)).toHaveLength(1);
|
|
233
|
+
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
234
|
+
expect(await countAssignments(otherTenant.tenantId)).toBe(0);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// The access option must reach the runtime: a host mounting folders with
|
|
239
|
+
// openToAll lets a user WITHOUT any default folder role file freely (the exact
|
|
240
|
+
// failure that bit money-horse, whose signup users carry "Admin").
|
|
241
|
+
describe("folders integration — openToAll access model", () => {
|
|
242
|
+
let openStack: TestStack;
|
|
243
|
+
const unprivileged = createTestUser({ roles: ["Viewer"] });
|
|
244
|
+
|
|
245
|
+
beforeAll(async () => {
|
|
246
|
+
openStack = await setupTestStack({
|
|
247
|
+
features: [createFoldersFeature({ access: { openToAll: true } })],
|
|
248
|
+
});
|
|
249
|
+
await unsafeCreateEntityTable(openStack.db, folderEntity);
|
|
250
|
+
await unsafeCreateEntityTable(openStack.db, folderAssignmentEntity);
|
|
251
|
+
await createEventsTable(openStack.db);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
afterAll(async () => {
|
|
255
|
+
await openStack.cleanup();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("a non-folder-role user can create, set, list and clear", async () => {
|
|
259
|
+
const folder = await openStack.http.writeOk<{ id: string }>(
|
|
260
|
+
FoldersHandlers.createFolder,
|
|
261
|
+
{ name: "Ordner A" },
|
|
262
|
+
unprivileged,
|
|
263
|
+
);
|
|
264
|
+
await openStack.http.writeOk(
|
|
265
|
+
FoldersHandlers.setFolder,
|
|
266
|
+
{ folderId: folder.id, entityType: "credit", entityId: "c-1" },
|
|
267
|
+
unprivileged,
|
|
268
|
+
);
|
|
269
|
+
const folders = await openStack.http.queryOk<{ rows: unknown[] }>(
|
|
270
|
+
FoldersQueries.folderList,
|
|
271
|
+
{},
|
|
272
|
+
unprivileged,
|
|
273
|
+
);
|
|
274
|
+
expect(folders.rows).toHaveLength(1);
|
|
275
|
+
await openStack.http.writeOk(
|
|
276
|
+
FoldersHandlers.clearFolder,
|
|
277
|
+
{ entityType: "credit", entityId: "c-1" },
|
|
278
|
+
unprivileged,
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("the SAME user is denied on a default-role-mounted feature", async () => {
|
|
283
|
+
const denied = await stack.http.writeErr(
|
|
284
|
+
FoldersHandlers.createFolder,
|
|
285
|
+
{ name: "nope" },
|
|
286
|
+
unprivileged,
|
|
287
|
+
);
|
|
288
|
+
expect(denied.httpStatus).toBe(403);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { v5 as uuidv5 } from "uuid";
|
|
2
|
+
|
|
3
|
+
// Fixed UUID namespace for folder-assignment aggregate-id derivation. Frozen:
|
|
4
|
+
// changing it would re-key every existing assignment stream → broken replay.
|
|
5
|
+
// Drift-pinned in __tests__.
|
|
6
|
+
const FOLDER_ASSIGNMENT_NAMESPACE = "b2e1f4a7-3c9d-4e8b-a1f6-5d2c8b3e7a9f";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Deterministic aggregate-id for a folder-assignment from the tuple
|
|
10
|
+
* (tenantId, entityType, entityId) — note: NO folderId. Exactly one aggregate
|
|
11
|
+
* exists per (tenant, entity), so an entity belongs to at most one folder;
|
|
12
|
+
* re-assigning to a different folder updates the same stream (move) instead of
|
|
13
|
+
* creating a second row. This is the single-membership counterpart to tags'
|
|
14
|
+
* many-to-many aggregate-id (which includes the tagId).
|
|
15
|
+
*/
|
|
16
|
+
// @wrapper-known uuid-domain
|
|
17
|
+
export function folderAssignmentAggregateId(
|
|
18
|
+
tenantId: string,
|
|
19
|
+
entityType: string,
|
|
20
|
+
entityId: string,
|
|
21
|
+
): string {
|
|
22
|
+
return uuidv5(`${tenantId}|${entityType}|${entityId}`, FOLDER_ASSIGNMENT_NAMESPACE);
|
|
23
|
+
}
|