@cosmicdrift/kumiko-bundled-features 0.174.0 → 0.176.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 +8 -10
- package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
- package/src/auth-email-password/__tests__/login-gates.test.ts +12 -6
- package/src/auth-email-password/seeding.ts +4 -2
- package/src/auth-mfa/__tests__/token-secrets.test.ts +19 -0
- package/src/auth-mfa/handlers/reencrypt.job.ts +11 -20
- package/src/auth-mfa/token-secrets.ts +16 -14
- package/src/billing-foundation/subscription-tier-sync.ts +8 -1
- package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +98 -0
- package/src/cap-counter/stock-cap-guard.ts +1 -1
- package/src/config/handlers/reencrypt.job.ts +7 -18
- package/src/delivery/delivery-service.ts +1 -1
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +47 -11
- package/src/document-ingest-foundation/__tests__/feature.test.ts +6 -2
- package/src/document-ingest-foundation/events.ts +15 -0
- package/src/document-ingest-foundation/feature.ts +27 -2
- package/src/inbound-mail-foundation/watch-supervisor.ts +11 -2
- package/src/jobs/handlers/catalog.query.ts +1 -4
- package/src/jobs/handlers/trigger.write.ts +2 -1
- package/src/jobs/is-manual-trigger.ts +5 -0
- package/src/legal-pages/README.md +26 -25
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
- package/src/legal-pages/feature.ts +40 -31
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
- package/src/legal-pages/web/client-plugin.ts +3 -3
- package/src/managed-pages/screens/branding-screen.ts +1 -1
- package/src/presets/dsgvo-self-service.ts +10 -10
- package/src/seo/__tests__/robots-txt.test.ts +21 -0
- package/src/seo/__tests__/seo.integration.test.ts +13 -13
- package/src/seo/robots-txt.ts +21 -1
- package/src/sessions/handlers/revoke-all-for-user.write.ts +10 -1
- package/src/shared/classify-stored-envelope.ts +21 -0
- package/src/shared/index.ts +4 -0
- package/src/template-resolver/README.md +30 -1
- package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
- package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
- package/src/template-resolver/api.ts +11 -7
- package/src/template-resolver/constants.ts +22 -0
- package/src/template-resolver/feature.ts +16 -1
- package/src/template-resolver/handlers/by-slug.query.ts +54 -0
- package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
- package/src/template-resolver/handlers/set.write.ts +110 -0
- package/src/template-resolver/handlers/shared.ts +13 -2
- package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
- package/src/template-resolver/index.ts +7 -0
- package/src/template-resolver/qualified-names.ts +3 -0
- package/src/template-resolver/seeding.ts +126 -2
- package/src/template-resolver/table.ts +11 -2
- package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
- package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
- package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
- package/src/template-resolver/web/client-plugin.tsx +352 -0
- package/src/template-resolver/web/index.ts +8 -0
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +9 -0
- package/src/tenant-settings/tenant-defaults.ts +3 -0
- package/src/tier-engine/compose-app.ts +2 -13
- package/src/user/__tests__/seed-testing.integration.test.ts +25 -0
- package/src/user/seeding.ts +25 -1
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +2 -2
- package/src/user-data-rights/__tests__/request-deletion-url.test.ts +11 -8
- package/src/user-data-rights/feature.ts +3 -2
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +8 -6
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +5 -5
- package/src/user-data-rights/web/confirm-deletion-screen.tsx +3 -2
- package/src/text-content/README.md +0 -197
- package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
- package/src/text-content/api.ts +0 -86
- package/src/text-content/changes.json +0 -14
- package/src/text-content/constants.ts +0 -21
- package/src/text-content/feature.ts +0 -53
- package/src/text-content/handlers/by-slug.query.ts +0 -55
- package/src/text-content/handlers/by-tenant.query.ts +0 -60
- package/src/text-content/handlers/set.write.ts +0 -141
- package/src/text-content/index.ts +0 -14
- package/src/text-content/seeding.ts +0 -139
- package/src/text-content/table.ts +0 -53
- package/src/text-content/web/client-plugin.tsx +0 -391
- package/src/text-content/web/index.ts +0 -8
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createTenantDb,
|
|
4
|
+
type DbConnection,
|
|
5
|
+
fetchOne,
|
|
6
|
+
selectMany,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
|
+
import {
|
|
11
|
+
createTestUser,
|
|
12
|
+
setupTestStack,
|
|
13
|
+
type TestStack,
|
|
14
|
+
TestUsers,
|
|
15
|
+
unsafeCreateEntityTable,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
17
|
+
import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
18
|
+
import { TEXT_BLOCK_KIND } from "../constants";
|
|
19
|
+
import { createTemplateResolverFeature } from "../feature";
|
|
20
|
+
import { executor } from "../handlers/shared";
|
|
21
|
+
import { TemplateResolverHandlers, TemplateResolverQueries } from "../qualified-names";
|
|
22
|
+
import { seedTextBlock } from "../seeding";
|
|
23
|
+
import { type TemplateResourceRow, templateResourceEntity, templateResourcesTable } from "../table";
|
|
24
|
+
|
|
25
|
+
let stack: TestStack;
|
|
26
|
+
let db: DbConnection;
|
|
27
|
+
|
|
28
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
29
|
+
const tenantAdmin = createTestUser({ id: 2, roles: ["TenantAdmin"] });
|
|
30
|
+
const normalUser = createTestUser({ id: 3 });
|
|
31
|
+
|
|
32
|
+
const feature = createTemplateResolverFeature();
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
stack = await setupTestStack({ features: [feature] });
|
|
36
|
+
db = stack.db;
|
|
37
|
+
await unsafeCreateEntityTable(db, templateResourceEntity);
|
|
38
|
+
await createEventsTable(db);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
await stack.cleanup();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("text-blocks :: write", () => {
|
|
46
|
+
test("TenantAdmin can create a text block", async () => {
|
|
47
|
+
const result = await stack.http.writeOk<Record<string, unknown>>(
|
|
48
|
+
TemplateResolverHandlers.set,
|
|
49
|
+
{
|
|
50
|
+
slug: "imprint",
|
|
51
|
+
locale: "de",
|
|
52
|
+
title: "Impressum",
|
|
53
|
+
content: "## Angaben gemäß § 5 TMG\n\nMarc Frost",
|
|
54
|
+
},
|
|
55
|
+
tenantAdmin,
|
|
56
|
+
);
|
|
57
|
+
expect(result).toMatchObject({ slug: "imprint", locale: "de", isNew: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("set is idempotent — second call updates existing block", async () => {
|
|
61
|
+
await stack.http.writeOk(
|
|
62
|
+
TemplateResolverHandlers.set,
|
|
63
|
+
{ slug: "privacy", locale: "de", title: "Datenschutz v1", content: "alt" },
|
|
64
|
+
tenantAdmin,
|
|
65
|
+
);
|
|
66
|
+
const result = await stack.http.writeOk<Record<string, unknown>>(
|
|
67
|
+
TemplateResolverHandlers.set,
|
|
68
|
+
{ slug: "privacy", locale: "de", title: "Datenschutz v2", content: "neu" },
|
|
69
|
+
tenantAdmin,
|
|
70
|
+
);
|
|
71
|
+
expect(result).toMatchObject({ slug: "privacy", isNew: false });
|
|
72
|
+
|
|
73
|
+
const fetched = await stack.http.queryOk<Record<string, unknown>>(
|
|
74
|
+
TemplateResolverQueries.bySlug,
|
|
75
|
+
{ slug: "privacy", locale: "de" },
|
|
76
|
+
tenantAdmin,
|
|
77
|
+
);
|
|
78
|
+
expect(fetched).toMatchObject({ title: "Datenschutz v2", content: "neu" });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("SystemAdmin can create text blocks for SYSTEM_TENANT (without TenantAdmin role)", async () => {
|
|
82
|
+
// SystemAdmin is global and has NO implicit TenantAdmin on its
|
|
83
|
+
// membership. The set-handler ACL must explicitly allow SystemAdmin,
|
|
84
|
+
// otherwise no one could set platform texts (e.g. imprint).
|
|
85
|
+
const result = await stack.http.writeOk<Record<string, unknown>>(
|
|
86
|
+
TemplateResolverHandlers.set,
|
|
87
|
+
{
|
|
88
|
+
slug: "system-imprint-write",
|
|
89
|
+
locale: "de",
|
|
90
|
+
title: "System-Impressum",
|
|
91
|
+
content: "## Plattform\n\nMarc",
|
|
92
|
+
},
|
|
93
|
+
systemAdmin,
|
|
94
|
+
);
|
|
95
|
+
expect(result).toMatchObject({ slug: "system-imprint-write", isNew: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("normal User cannot create text blocks (access denied)", async () => {
|
|
99
|
+
const error = await stack.http.writeErr(
|
|
100
|
+
TemplateResolverHandlers.set,
|
|
101
|
+
{ slug: "about", locale: "de", title: "Über", content: null },
|
|
102
|
+
normalUser,
|
|
103
|
+
);
|
|
104
|
+
expectErrorIncludes(error, "access_denied");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("invalid slug rejected by schema validation", async () => {
|
|
108
|
+
const error = await stack.http.writeErr(
|
|
109
|
+
TemplateResolverHandlers.set,
|
|
110
|
+
{ slug: "Invalid Slug!", locale: "de", title: "x", content: null },
|
|
111
|
+
tenantAdmin,
|
|
112
|
+
);
|
|
113
|
+
expectErrorIncludes(error, "validation_error");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("SystemAdmin can write with tenantIdOverride to a different tenant (legal-pages use-case)", async () => {
|
|
117
|
+
// Use-case: a platform app's edit UI loads a SystemAdmin who is NOT
|
|
118
|
+
// a member of SYSTEM_TENANT and lets them write there.
|
|
119
|
+
// Without override, the text would land on systemAdmin.tenantId
|
|
120
|
+
// instead of SYSTEM_TENANT — legal-pages routes would never read it.
|
|
121
|
+
const targetTenant = createTestUser({ id: 99 }).tenantId;
|
|
122
|
+
const result = await stack.http.writeOk<Record<string, unknown>>(
|
|
123
|
+
TemplateResolverHandlers.set,
|
|
124
|
+
{
|
|
125
|
+
slug: "override-target",
|
|
126
|
+
locale: "de",
|
|
127
|
+
title: "Override-Test",
|
|
128
|
+
content: "via tenantIdOverride",
|
|
129
|
+
tenantIdOverride: targetTenant,
|
|
130
|
+
},
|
|
131
|
+
systemAdmin,
|
|
132
|
+
);
|
|
133
|
+
expect(result).toMatchObject({ slug: "override-target", isNew: true });
|
|
134
|
+
|
|
135
|
+
// Proof: text landed on the TARGET tenant, not on systemAdmin's
|
|
136
|
+
// own tenant. Reading with the same override returns the block.
|
|
137
|
+
const read = await stack.http.queryOk<Record<string, unknown>>(
|
|
138
|
+
TemplateResolverQueries.bySlug,
|
|
139
|
+
{ slug: "override-target", locale: "de", tenantIdOverride: targetTenant },
|
|
140
|
+
systemAdmin,
|
|
141
|
+
);
|
|
142
|
+
expect(read).toMatchObject({ slug: "override-target", title: "Override-Test" });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("SystemAdmin can UPDATE with tenantIdOverride (regression: stream-lookup must use override-tenantId, not user.tenantId)", async () => {
|
|
146
|
+
// Regression guard for 2026-05-04: with tenantIdOverride, the
|
|
147
|
+
// user-context for the event-store executor MUST also be remapped —
|
|
148
|
+
// otherwise append() lands on user.tenantId but getStreamVersion (on
|
|
149
|
+
// update) also looks up user.tenantId, finding only the stream on
|
|
150
|
+
// override-tenantId from the first write → version_conflict even
|
|
151
|
+
// though the projection row is there. A test with only create+override
|
|
152
|
+
// did not catch the bug because append=create has no stream lookup.
|
|
153
|
+
const targetTenant = createTestUser({ id: 77 }).tenantId;
|
|
154
|
+
|
|
155
|
+
// Step 1: create with override.
|
|
156
|
+
await stack.http.writeOk<Record<string, unknown>>(
|
|
157
|
+
TemplateResolverHandlers.set,
|
|
158
|
+
{
|
|
159
|
+
slug: "update-target",
|
|
160
|
+
locale: "de",
|
|
161
|
+
title: "v1",
|
|
162
|
+
content: "first",
|
|
163
|
+
tenantIdOverride: targetTenant,
|
|
164
|
+
},
|
|
165
|
+
systemAdmin,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Step 2: UPDATE with override (same slug+lang+target). Before the
|
|
169
|
+
// fix: version_conflict. After the fix: clean update.
|
|
170
|
+
const result = await stack.http.writeOk<Record<string, unknown>>(
|
|
171
|
+
TemplateResolverHandlers.set,
|
|
172
|
+
{
|
|
173
|
+
slug: "update-target",
|
|
174
|
+
locale: "de",
|
|
175
|
+
title: "v2",
|
|
176
|
+
content: "updated",
|
|
177
|
+
tenantIdOverride: targetTenant,
|
|
178
|
+
},
|
|
179
|
+
systemAdmin,
|
|
180
|
+
);
|
|
181
|
+
expect(result).toMatchObject({ slug: "update-target", isNew: false });
|
|
182
|
+
|
|
183
|
+
// Proof: read returns the UPDATED content on the TARGET tenant.
|
|
184
|
+
const read = await stack.http.queryOk<Record<string, unknown>>(
|
|
185
|
+
TemplateResolverQueries.bySlug,
|
|
186
|
+
{ slug: "update-target", locale: "de", tenantIdOverride: targetTenant },
|
|
187
|
+
systemAdmin,
|
|
188
|
+
);
|
|
189
|
+
expect(read).toMatchObject({ slug: "update-target", title: "v2", content: "updated" });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("TenantAdmin's tenantIdOverride attempt → 403 access_denied", async () => {
|
|
193
|
+
// Defense-in-depth: override is SystemAdmin-only. TenantAdmin
|
|
194
|
+
// must NOT be able to write to other tenants — otherwise a
|
|
195
|
+
// tenant admin of tenant A could simply overwrite tenant B's imprint.
|
|
196
|
+
const otherTenant = createTestUser({ id: 88 }).tenantId;
|
|
197
|
+
const error = await stack.http.writeErr(
|
|
198
|
+
TemplateResolverHandlers.set,
|
|
199
|
+
{
|
|
200
|
+
slug: "evil-override",
|
|
201
|
+
locale: "de",
|
|
202
|
+
title: "evil",
|
|
203
|
+
content: null,
|
|
204
|
+
tenantIdOverride: otherTenant,
|
|
205
|
+
},
|
|
206
|
+
tenantAdmin,
|
|
207
|
+
);
|
|
208
|
+
expectErrorIncludes(error, "access_denied");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("invalid lang rejected by schema validation", async () => {
|
|
212
|
+
const error = await stack.http.writeErr(
|
|
213
|
+
TemplateResolverHandlers.set,
|
|
214
|
+
{ slug: "ok", locale: "DEUTSCH", title: "x", content: null },
|
|
215
|
+
tenantAdmin,
|
|
216
|
+
);
|
|
217
|
+
expectErrorIncludes(error, "validation_error");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("update only touches the editable columns — variableSchema/status survive a save", async () => {
|
|
221
|
+
// set.write shares the table with the template upserts. If it wrote its
|
|
222
|
+
// whole field set on update, every editor save would reset a row's
|
|
223
|
+
// variableSchema to {} and force status back to active.
|
|
224
|
+
await stack.http.writeOk(
|
|
225
|
+
TemplateResolverHandlers.set,
|
|
226
|
+
{ slug: "shared-row", locale: "de", title: "v1", content: "first" },
|
|
227
|
+
tenantAdmin,
|
|
228
|
+
);
|
|
229
|
+
const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
230
|
+
tenantId: tenantAdmin.tenantId,
|
|
231
|
+
slug: "shared-row",
|
|
232
|
+
kind: TEXT_BLOCK_KIND,
|
|
233
|
+
locale: "de",
|
|
234
|
+
});
|
|
235
|
+
await executor.update(
|
|
236
|
+
{
|
|
237
|
+
id: String(row!.id),
|
|
238
|
+
version: row!.version,
|
|
239
|
+
changes: { variableSchema: JSON.stringify({ firstName: "string" }), status: "archived" },
|
|
240
|
+
},
|
|
241
|
+
createSystemUser(tenantAdmin.tenantId),
|
|
242
|
+
createTenantDb(db, tenantAdmin.tenantId, "system"),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
await stack.http.writeOk(
|
|
246
|
+
TemplateResolverHandlers.set,
|
|
247
|
+
{ slug: "shared-row", locale: "de", title: "v2", content: "second" },
|
|
248
|
+
tenantAdmin,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const after = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
252
|
+
tenantId: tenantAdmin.tenantId,
|
|
253
|
+
slug: "shared-row",
|
|
254
|
+
kind: TEXT_BLOCK_KIND,
|
|
255
|
+
locale: "de",
|
|
256
|
+
});
|
|
257
|
+
expect(after).toMatchObject({
|
|
258
|
+
title: "v2",
|
|
259
|
+
content: "second",
|
|
260
|
+
variableSchema: JSON.stringify({ firstName: "string" }),
|
|
261
|
+
status: "archived",
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("text-blocks :: query (openToAll)", () => {
|
|
267
|
+
test("by-slug returns existing block for matching tenant/lang", async () => {
|
|
268
|
+
await seedTextBlock(db, {
|
|
269
|
+
tenantId: tenantAdmin.tenantId,
|
|
270
|
+
slug: "about",
|
|
271
|
+
locale: "de",
|
|
272
|
+
title: "Über uns",
|
|
273
|
+
content: "Wir sind ein Team.",
|
|
274
|
+
});
|
|
275
|
+
const result = await stack.http.queryOk<Record<string, unknown>>(
|
|
276
|
+
TemplateResolverQueries.bySlug,
|
|
277
|
+
{ slug: "about", locale: "de" },
|
|
278
|
+
tenantAdmin,
|
|
279
|
+
);
|
|
280
|
+
expect(result).toMatchObject({
|
|
281
|
+
slug: "about",
|
|
282
|
+
locale: "de",
|
|
283
|
+
title: "Über uns",
|
|
284
|
+
content: "Wir sind ein Team.",
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("by-slug returns null for missing block", async () => {
|
|
289
|
+
const result = await stack.http.queryOk<Record<string, unknown> | null>(
|
|
290
|
+
TemplateResolverQueries.bySlug,
|
|
291
|
+
{ slug: "does-not-exist", locale: "de" },
|
|
292
|
+
tenantAdmin,
|
|
293
|
+
);
|
|
294
|
+
expect(result).toBeFalsy();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("by-slug isolates by tenant — other tenant's block invisible", async () => {
|
|
298
|
+
const otherTenant = createTestUser({
|
|
299
|
+
id: 99,
|
|
300
|
+
tenantId: "11111111-1111-4111-8111-111111111111",
|
|
301
|
+
roles: ["TenantAdmin"],
|
|
302
|
+
});
|
|
303
|
+
await seedTextBlock(db, {
|
|
304
|
+
tenantId: tenantAdmin.tenantId,
|
|
305
|
+
slug: "tenant-only",
|
|
306
|
+
locale: "de",
|
|
307
|
+
title: "Tenant-A only",
|
|
308
|
+
});
|
|
309
|
+
const result = await stack.http.queryOk<Record<string, unknown> | null>(
|
|
310
|
+
TemplateResolverQueries.bySlug,
|
|
311
|
+
{ slug: "tenant-only", locale: "de" },
|
|
312
|
+
otherTenant,
|
|
313
|
+
);
|
|
314
|
+
expect(result).toBeNull();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("by-slug works for SystemAdmin scoped to system tenant", async () => {
|
|
318
|
+
await seedTextBlock(db, {
|
|
319
|
+
tenantId: systemAdmin.tenantId,
|
|
320
|
+
slug: "system-imprint",
|
|
321
|
+
locale: "de",
|
|
322
|
+
title: "System-Impressum",
|
|
323
|
+
content: "Plattform-Betreiber",
|
|
324
|
+
});
|
|
325
|
+
const result = await stack.http.queryOk<Record<string, unknown>>(
|
|
326
|
+
TemplateResolverQueries.bySlug,
|
|
327
|
+
{ slug: "system-imprint", locale: "de" },
|
|
328
|
+
systemAdmin,
|
|
329
|
+
);
|
|
330
|
+
expect(result).toMatchObject({ title: "System-Impressum" });
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("text-blocks :: edge-cases", () => {
|
|
335
|
+
test("body=null roundtrip — set + query liefert null body zurück", async () => {
|
|
336
|
+
// Realistic use-case: a tenant admin creates an empty block as a
|
|
337
|
+
// stub (e.g. during onboarding) and fills it in later.
|
|
338
|
+
await stack.http.writeOk<Record<string, unknown>>(
|
|
339
|
+
TemplateResolverHandlers.set,
|
|
340
|
+
{ slug: "stub-page", locale: "de", title: "Wird noch gefüllt", content: null },
|
|
341
|
+
tenantAdmin,
|
|
342
|
+
);
|
|
343
|
+
const fetched = await stack.http.queryOk<Record<string, unknown>>(
|
|
344
|
+
TemplateResolverQueries.bySlug,
|
|
345
|
+
{ slug: "stub-page", locale: "de" },
|
|
346
|
+
tenantAdmin,
|
|
347
|
+
);
|
|
348
|
+
expect(fetched).toMatchObject({ title: "Wird noch gefüllt", content: null });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("body=null kann via update auf string gesetzt werden", async () => {
|
|
352
|
+
await stack.http.writeOk(
|
|
353
|
+
TemplateResolverHandlers.set,
|
|
354
|
+
{ slug: "later-filled", locale: "de", title: "Stub", content: null },
|
|
355
|
+
tenantAdmin,
|
|
356
|
+
);
|
|
357
|
+
await stack.http.writeOk(
|
|
358
|
+
TemplateResolverHandlers.set,
|
|
359
|
+
{ slug: "later-filled", locale: "de", title: "Stub", content: "Inhalt" },
|
|
360
|
+
tenantAdmin,
|
|
361
|
+
);
|
|
362
|
+
const fetched = await stack.http.queryOk<Record<string, unknown>>(
|
|
363
|
+
TemplateResolverQueries.bySlug,
|
|
364
|
+
{ slug: "later-filled", locale: "de" },
|
|
365
|
+
tenantAdmin,
|
|
366
|
+
);
|
|
367
|
+
expect(fetched!["content"]).toBe("Inhalt");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("body knapp unter max-length (200k Zeichen) wird akzeptiert", async () => {
|
|
371
|
+
const justBelowMax = "a".repeat(200_000);
|
|
372
|
+
const result = await stack.http.writeOk<Record<string, unknown>>(
|
|
373
|
+
TemplateResolverHandlers.set,
|
|
374
|
+
{ slug: "max-length-ok", locale: "de", title: "Max", content: justBelowMax },
|
|
375
|
+
tenantAdmin,
|
|
376
|
+
);
|
|
377
|
+
expect(result).toMatchObject({ slug: "max-length-ok", isNew: true });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("body über max-length (200k+1 Zeichen) → validation_error", async () => {
|
|
381
|
+
const overLimit = "a".repeat(200_001);
|
|
382
|
+
const error = await stack.http.writeErr(
|
|
383
|
+
TemplateResolverHandlers.set,
|
|
384
|
+
{ slug: "max-length-fail", locale: "de", title: "Over", content: overLimit },
|
|
385
|
+
tenantAdmin,
|
|
386
|
+
);
|
|
387
|
+
expectErrorIncludes(error, "validation_error");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("body mit XSS-Payload wird unverändert gespeichert (Markdown-Renderer ist verantwortlich für Escaping)", async () => {
|
|
391
|
+
// Documented behavior: text-content stores markdown 1:1.
|
|
392
|
+
// Consumers (e.g. legal-pages with `marked`) must decide whether
|
|
393
|
+
// to sanitize — see legal-pages/README.md XSS section.
|
|
394
|
+
const xssPayload = "## Title\n\n<script>alert('xss')</script>\n\nText.";
|
|
395
|
+
await stack.http.writeOk(
|
|
396
|
+
TemplateResolverHandlers.set,
|
|
397
|
+
{ slug: "xss-test", locale: "de", title: "XSS", content: xssPayload },
|
|
398
|
+
tenantAdmin,
|
|
399
|
+
);
|
|
400
|
+
const fetched = await stack.http.queryOk<Record<string, unknown>>(
|
|
401
|
+
TemplateResolverQueries.bySlug,
|
|
402
|
+
{ slug: "xss-test", locale: "de" },
|
|
403
|
+
tenantAdmin,
|
|
404
|
+
);
|
|
405
|
+
// Roundtrip: body stays exactly what was written in
|
|
406
|
+
expect(fetched!["content"]).toBe(xssPayload);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("concurrent set auf gleichen (tenantId, slug, lang) — mindestens einer succeed", async () => {
|
|
410
|
+
// Race test: two tenant admins (or the same admin from two tabs)
|
|
411
|
+
// write concurrently. fetchOne+update is not atomic — if both find
|
|
412
|
+
// the same `existing` and both try to update, optimistic locking
|
|
413
|
+
// via the executor's version check kicks in.
|
|
414
|
+
// Expectation: one succeeds, one may throw version_conflict
|
|
415
|
+
// (or both succeed if sequential enough). At least one
|
|
416
|
+
// must go through, otherwise the race path is broken.
|
|
417
|
+
await stack.http.writeOk(
|
|
418
|
+
TemplateResolverHandlers.set,
|
|
419
|
+
{ slug: "race-test", locale: "de", title: "Initial", content: "v1" },
|
|
420
|
+
tenantAdmin,
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const results = await Promise.allSettled([
|
|
424
|
+
stack.http.writeOk(
|
|
425
|
+
TemplateResolverHandlers.set,
|
|
426
|
+
{ slug: "race-test", locale: "de", title: "A", content: "from-a" },
|
|
427
|
+
tenantAdmin,
|
|
428
|
+
),
|
|
429
|
+
stack.http.writeOk(
|
|
430
|
+
TemplateResolverHandlers.set,
|
|
431
|
+
{ slug: "race-test", locale: "de", title: "B", content: "from-b" },
|
|
432
|
+
tenantAdmin,
|
|
433
|
+
),
|
|
434
|
+
]);
|
|
435
|
+
const succeeded = results.filter((r) => r.status === "fulfilled").length;
|
|
436
|
+
expect(succeeded).toBeGreaterThanOrEqual(1);
|
|
437
|
+
|
|
438
|
+
// Whichever wins — the row is consistent after both calls
|
|
439
|
+
// with one of the two values (no partial state).
|
|
440
|
+
const fetched = await stack.http.queryOk<Record<string, unknown>>(
|
|
441
|
+
TemplateResolverQueries.bySlug,
|
|
442
|
+
{ slug: "race-test", locale: "de" },
|
|
443
|
+
tenantAdmin,
|
|
444
|
+
);
|
|
445
|
+
const finalBody = fetched!["content"] as string;
|
|
446
|
+
expect(["from-a", "from-b", "v1"]).toContain(finalBody);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe("text-blocks :: seedTextBlock", () => {
|
|
451
|
+
test('ifExists="update" overwrites existing row (same aggregate id)', async () => {
|
|
452
|
+
const a = await seedTextBlock(db, {
|
|
453
|
+
tenantId: tenantAdmin.tenantId,
|
|
454
|
+
slug: "seed-test",
|
|
455
|
+
locale: "de",
|
|
456
|
+
title: "v1",
|
|
457
|
+
content: "alt",
|
|
458
|
+
});
|
|
459
|
+
const b = await seedTextBlock(db, {
|
|
460
|
+
tenantId: tenantAdmin.tenantId,
|
|
461
|
+
slug: "seed-test",
|
|
462
|
+
locale: "de",
|
|
463
|
+
title: "v2",
|
|
464
|
+
content: "neu",
|
|
465
|
+
ifExists: "update",
|
|
466
|
+
});
|
|
467
|
+
expect(a.id).toBe(b.id);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('default ifExists="skip" does not overwrite on re-boot', async () => {
|
|
471
|
+
const base = {
|
|
472
|
+
tenantId: tenantAdmin.tenantId,
|
|
473
|
+
slug: "seed-skip",
|
|
474
|
+
locale: "de",
|
|
475
|
+
};
|
|
476
|
+
await seedTextBlock(db, {
|
|
477
|
+
...base,
|
|
478
|
+
title: "Initial",
|
|
479
|
+
content: "from seed",
|
|
480
|
+
});
|
|
481
|
+
await seedTextBlock(db, {
|
|
482
|
+
...base,
|
|
483
|
+
title: "User edit",
|
|
484
|
+
content: "from admin",
|
|
485
|
+
ifExists: "update",
|
|
486
|
+
});
|
|
487
|
+
await seedTextBlock(db, {
|
|
488
|
+
...base,
|
|
489
|
+
title: "Seed again",
|
|
490
|
+
content: "would overwrite",
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
494
|
+
...base,
|
|
495
|
+
kind: TEXT_BLOCK_KIND,
|
|
496
|
+
});
|
|
497
|
+
expect(row).toMatchObject({ title: "User edit", content: "from admin", version: 2 });
|
|
498
|
+
|
|
499
|
+
const events = await selectMany(db, eventsTable, {
|
|
500
|
+
aggregateId: String(row!.id),
|
|
501
|
+
});
|
|
502
|
+
expect(events).toHaveLength(2);
|
|
503
|
+
// Sorted: selectMany has no ORDER BY and both events share a timestamp.
|
|
504
|
+
expect(events.map((e) => e.type).sort()).toEqual([
|
|
505
|
+
"template-resource.created",
|
|
506
|
+
"template-resource.updated",
|
|
507
|
+
]);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// Drift documentation: seedTextBlock goes directly through the executor
|
|
511
|
+
// WITHOUT slugSchema validation, set.write goes THROUGH the validation.
|
|
512
|
+
// Consequence: seedTextBlock accepts slugs with ":" or "/" (legal-pages
|
|
513
|
+
// platform seeds use this for "page:index:hero.title" etc.), but
|
|
514
|
+
// a user edit of the same block via set.write would fail with
|
|
515
|
+
// validation_error (regex `^[a-z0-9][a-z0-9-]*$`). The drift is
|
|
516
|
+
// **deliberate** in V.1.3 — seedTextBlock is system-trusted (boot fixture,
|
|
517
|
+
// no user input). V.1.4 plans a real `folder` field instead of a
|
|
518
|
+
// `:` separator in the slug, then the drift goes away.
|
|
519
|
+
//
|
|
520
|
+
// This test pins the status quo: the editor form via set.write rejects
|
|
521
|
+
// ":" slugs even if seedTextBlock created them. This test guards
|
|
522
|
+
// against someone silently adding seedTextBlock validation
|
|
523
|
+
// without converting app-side seed slugs (e.g. legal-pages platform
|
|
524
|
+
// seeds).
|
|
525
|
+
test("seedTextBlock + set.write drift: `:`-slugs sind seed-only, set.write rejected sie", async () => {
|
|
526
|
+
// Seed with `:` slug works (legal-pages pattern)
|
|
527
|
+
const seeded = await seedTextBlock(db, {
|
|
528
|
+
tenantId: tenantAdmin.tenantId,
|
|
529
|
+
slug: "page:hero",
|
|
530
|
+
locale: "de",
|
|
531
|
+
title: "Seeded",
|
|
532
|
+
content: "from-seed",
|
|
533
|
+
});
|
|
534
|
+
expect(seeded.id).toBeDefined();
|
|
535
|
+
|
|
536
|
+
// set.write on the same slug → validation_error (kebab-only regex)
|
|
537
|
+
const error = await stack.http.writeErr(
|
|
538
|
+
TemplateResolverHandlers.set,
|
|
539
|
+
{ slug: "page:hero", locale: "de", title: "User-edit", content: "from-write" },
|
|
540
|
+
tenantAdmin,
|
|
541
|
+
);
|
|
542
|
+
expectErrorIncludes(error, "validation_error");
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("seedTextBlock + set.write parity: kebab-only Slugs durchlaufen beide Pfade", async () => {
|
|
546
|
+
// Inverse test: for kebab-only slugs (`page-hero`) both paths
|
|
547
|
+
// work. App builders who want edit-form-capable seeds must
|
|
548
|
+
// use kebab-only (see publicstatus/bin/seed-demo.ts).
|
|
549
|
+
await seedTextBlock(db, {
|
|
550
|
+
tenantId: tenantAdmin.tenantId,
|
|
551
|
+
slug: "page-hero",
|
|
552
|
+
locale: "de",
|
|
553
|
+
title: "Seeded",
|
|
554
|
+
content: "from-seed",
|
|
555
|
+
});
|
|
556
|
+
const result = await stack.http.writeOk<{ slug: string; locale: string }>(
|
|
557
|
+
TemplateResolverHandlers.set,
|
|
558
|
+
{ slug: "page-hero", locale: "de", title: "User-edit", content: "from-write" },
|
|
559
|
+
tenantAdmin,
|
|
560
|
+
);
|
|
561
|
+
expect(result.slug).toBe("page-hero");
|
|
562
|
+
});
|
|
563
|
+
});
|
|
@@ -7,7 +7,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
7
7
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
8
8
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
9
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
|
-
import type { ContentFormat,
|
|
10
|
+
import type { ContentFormat, TemplateKind } from "./constants";
|
|
11
11
|
import { FALLBACK_LOCALE, SYSTEM_TENANT_ID } from "./constants";
|
|
12
12
|
import { type TemplateResourceRow, templateResourcesTable } from "./table";
|
|
13
13
|
|
|
@@ -19,8 +19,10 @@ export type TemplateResource = {
|
|
|
19
19
|
readonly version: number;
|
|
20
20
|
readonly tenantId: string;
|
|
21
21
|
readonly slug: string;
|
|
22
|
-
readonly kind:
|
|
22
|
+
readonly kind: TemplateKind;
|
|
23
23
|
readonly locale: string;
|
|
24
|
+
readonly title: string | null;
|
|
25
|
+
readonly folder: string | null;
|
|
24
26
|
readonly content: string;
|
|
25
27
|
readonly contentFormat: ContentFormat;
|
|
26
28
|
readonly variableSchema: Record<string, unknown>;
|
|
@@ -34,7 +36,7 @@ export type TemplateResource = {
|
|
|
34
36
|
export type ResolveRequest = {
|
|
35
37
|
readonly tenantId: TenantId;
|
|
36
38
|
readonly slug: string;
|
|
37
|
-
readonly kind:
|
|
39
|
+
readonly kind: TemplateKind;
|
|
38
40
|
readonly locale: string;
|
|
39
41
|
};
|
|
40
42
|
|
|
@@ -53,7 +55,7 @@ export type TemplateResolverApi = {
|
|
|
53
55
|
readonly findExact: (args: {
|
|
54
56
|
readonly tenantId: TenantId;
|
|
55
57
|
readonly slug: string;
|
|
56
|
-
readonly kind:
|
|
58
|
+
readonly kind: TemplateKind;
|
|
57
59
|
readonly locale: string;
|
|
58
60
|
readonly scope?: "system";
|
|
59
61
|
}) => Promise<TemplateResource | null>;
|
|
@@ -108,7 +110,7 @@ async function fetchTemplate(
|
|
|
108
110
|
db: DbConnection,
|
|
109
111
|
tenantId: string,
|
|
110
112
|
slug: string,
|
|
111
|
-
kind:
|
|
113
|
+
kind: TemplateKind,
|
|
112
114
|
locale: string,
|
|
113
115
|
): Promise<TemplateResourceRow | null> {
|
|
114
116
|
const rows = await selectMany(
|
|
@@ -127,7 +129,7 @@ function toPublic(row: TemplateResourceRow): TemplateResource {
|
|
|
127
129
|
// scope/status als generic text. CHECK-Constraints in der DB schränken
|
|
128
130
|
// sie auf die Union-Types ein; Cast assertet das Schema-Wissen.
|
|
129
131
|
// linkedResources ist ein text-column mit JSON-payload (string→string map).
|
|
130
|
-
const kind = row.kind as
|
|
132
|
+
const kind = row.kind as TemplateKind;
|
|
131
133
|
// @cast-boundary db-row — siehe kind.
|
|
132
134
|
const contentFormat = row.contentFormat as ContentFormat;
|
|
133
135
|
// @cast-boundary db-row — siehe kind.
|
|
@@ -144,6 +146,8 @@ function toPublic(row: TemplateResourceRow): TemplateResource {
|
|
|
144
146
|
slug: row.slug,
|
|
145
147
|
kind,
|
|
146
148
|
locale: row.locale,
|
|
149
|
+
title: row.title,
|
|
150
|
+
folder: row.folder,
|
|
147
151
|
content: row.content ?? "",
|
|
148
152
|
contentFormat,
|
|
149
153
|
variableSchema: parseJson(row.variableSchema),
|
|
@@ -168,7 +172,7 @@ function parseJson(raw: string | null): Record<string, unknown> {
|
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
export class TemplateNotFoundError extends Error {
|
|
171
|
-
constructor(public readonly query: { slug: string; kind:
|
|
175
|
+
constructor(public readonly query: { slug: string; kind: TemplateKind; locale: string }) {
|
|
172
176
|
super(
|
|
173
177
|
`[template-resolver] no template found for slug="${query.slug}" kind="${query.kind}" locale="${query.locale}" (checked tenant + system, requested + fallback locale)`,
|
|
174
178
|
);
|
|
@@ -10,6 +10,28 @@ export const RENDER_KINDS = [
|
|
|
10
10
|
] as const;
|
|
11
11
|
export type RenderKind = (typeof RENDER_KINDS)[number];
|
|
12
12
|
|
|
13
|
+
// Kinds without a renderer plugin: `text-block` is the Markdown text store
|
|
14
|
+
// (ex text-content feature), `ai-prompt` holds LLM prompts. Both are read,
|
|
15
|
+
// not rendered — keeping them out of RENDER_KINDS is what stops
|
|
16
|
+
// DEFAULT_PLUGIN_BY_KIND from demanding a plugin for them.
|
|
17
|
+
export const NON_RENDER_KINDS = ["text-block", "ai-prompt"] as const;
|
|
18
|
+
export type NonRenderKind = (typeof NON_RENDER_KINDS)[number];
|
|
19
|
+
|
|
20
|
+
// Full kind domain of the entity; RENDER_KINDS is the renderer-foundation subset.
|
|
21
|
+
export const TEMPLATE_KINDS = [...RENDER_KINDS, ...NON_RENDER_KINDS] as const;
|
|
22
|
+
export type TemplateKind = (typeof TEMPLATE_KINDS)[number];
|
|
23
|
+
|
|
24
|
+
// Kinds written through upsertSystem/upsertTenant. `text-block` is absent on
|
|
25
|
+
// purpose: it goes through set.write (title + folder tree); two write paths
|
|
26
|
+
// onto the same row would drift apart.
|
|
27
|
+
export const UPSERT_KINDS = [...RENDER_KINDS, "ai-prompt"] as const;
|
|
28
|
+
export type UpsertKind = (typeof UPSERT_KINDS)[number];
|
|
29
|
+
|
|
30
|
+
// The only kind served by the anonymous-capable by-slug/by-tenant queries and
|
|
31
|
+
// by set.write — mail templates and AI prompts are operational internals and
|
|
32
|
+
// must not leak through the anonymous path.
|
|
33
|
+
export const TEXT_BLOCK_KIND = "text-block" as const;
|
|
34
|
+
|
|
13
35
|
export const CONTENT_FORMATS = ["markdown", "mjml", "html", "plain"] as const;
|
|
14
36
|
export type ContentFormat = (typeof CONTENT_FORMATS)[number];
|
|
15
37
|
|