@cosmicdrift/kumiko-bundled-features 0.50.0 → 0.51.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 -6
- package/src/config/__tests__/backing-secrets.integration.test.ts +188 -0
- package/src/config/__tests__/config.integration.test.ts +60 -0
- package/src/config/feature.ts +5 -2
- package/src/config/handlers/cascade.query.ts +4 -1
- package/src/config/handlers/readiness.query.ts +1 -0
- package/src/config/handlers/reset.write.ts +23 -2
- package/src/config/handlers/set.write.ts +36 -2
- package/src/config/handlers/values.query.ts +5 -1
- package/src/config/resolver.ts +93 -3
- package/src/config/write-helpers.ts +37 -0
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +162 -0
- package/src/jobs/feature.ts +13 -0
- package/src/jobs/handlers/projection-rebuild.job.ts +36 -0
- package/src/legal-pages/README.md +16 -13
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +15 -8
- package/src/legal-pages/feature.ts +9 -4
- package/src/legal-pages/markdown.ts +6 -56
- package/src/legal-pages/security-headers.ts +1 -0
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +536 -0
- package/src/managed-pages/branding.ts +142 -0
- package/src/managed-pages/css-gate.ts +24 -0
- package/src/managed-pages/feature.ts +246 -0
- package/src/managed-pages/handlers/branding.query.ts +30 -0
- package/src/managed-pages/handlers/by-slug.query.ts +35 -0
- package/src/managed-pages/handlers/set.write.ts +113 -0
- package/src/managed-pages/index.ts +30 -0
- package/src/managed-pages/screens/branding-screen.ts +85 -0
- package/src/managed-pages/screens/page-screens.ts +82 -0
- package/src/managed-pages/seeding.ts +99 -0
- package/src/managed-pages/table.ts +58 -0
- package/src/page-render/__tests__/branding.test.ts +57 -0
- package/src/page-render/__tests__/css-sanitize.test.ts +215 -0
- package/src/page-render/__tests__/markdown.test.ts +41 -0
- package/src/page-render/branding.ts +99 -0
- package/src/page-render/css-sanitize.ts +344 -0
- package/src/page-render/index.ts +13 -0
- package/src/page-render/layout.ts +100 -0
- package/src/page-render/markdown.ts +39 -0
- package/src/page-render/security-headers.ts +16 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
3
|
+
import {
|
|
4
|
+
createTestUser,
|
|
5
|
+
setupTestStack,
|
|
6
|
+
type TestStack,
|
|
7
|
+
unsafeCreateEntityTable,
|
|
8
|
+
unsafePushTables,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
10
|
+
import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
11
|
+
import { createConfigAccessorFactory, createConfigFeature } from "../../config/feature";
|
|
12
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
13
|
+
import { configValuesTable } from "../../config/table";
|
|
14
|
+
import { BRANDING_QN, BRANDING_QUERY_QN } from "../branding";
|
|
15
|
+
import { createManagedPagesCssFeature } from "../css-gate";
|
|
16
|
+
import { createManagedPagesFeature } from "../feature";
|
|
17
|
+
import { seedPage } from "../seeding";
|
|
18
|
+
import { pageEntity } from "../table";
|
|
19
|
+
|
|
20
|
+
const TENANT_A = "11111111-1111-4111-8111-111111111111";
|
|
21
|
+
const TENANT_B = "22222222-2222-4222-8222-222222222222";
|
|
22
|
+
|
|
23
|
+
// Authenticated Admin-Authoring-User. `createTestUser` ohne explizite
|
|
24
|
+
// tenantId teilt den Default-Tenant (nur `id` variiert die User-Id) — für
|
|
25
|
+
// die Cross-Tenant-Isolation braucht `otherAdmin` daher eine EXPLIZITE,
|
|
26
|
+
// distinkte tenantId.
|
|
27
|
+
const tenantAdmin = createTestUser({ id: 10, roles: ["TenantAdmin"] });
|
|
28
|
+
const otherAdmin = createTestUser({
|
|
29
|
+
id: 11,
|
|
30
|
+
roles: ["TenantAdmin"],
|
|
31
|
+
tenantId: "33333333-3333-4333-8333-333333333333",
|
|
32
|
+
});
|
|
33
|
+
const normalUser = createTestUser({ id: 12 });
|
|
34
|
+
|
|
35
|
+
let stack: TestStack;
|
|
36
|
+
|
|
37
|
+
// Host → Tenant: a.* → A, b.* → B, sonst kein Tenant (404). Steht für
|
|
38
|
+
// publicstatus' Subdomain-Auflösung bzw. studios eigene.
|
|
39
|
+
const managed = createManagedPagesFeature({
|
|
40
|
+
resolveApexTenant: (host) => {
|
|
41
|
+
if (host.startsWith("a.")) return TENANT_A;
|
|
42
|
+
if (host.startsWith("b.")) return TENANT_B;
|
|
43
|
+
return null;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// managed-pages declares `r.requires("config")` for the branding keys —
|
|
48
|
+
// the config feature must be in the stack and `ctx.config` wired.
|
|
49
|
+
const configFeature = createConfigFeature();
|
|
50
|
+
|
|
51
|
+
beforeAll(async () => {
|
|
52
|
+
// KEIN defaultTenantId — der lockt Single-Tenant-Modus und würde den
|
|
53
|
+
// per-Page-Route gesetzten X-Tenant (≠ default) mit 400 tenant_mismatch
|
|
54
|
+
// ablehnen. Multi-Tenant nutzt den X-Tenant-Header (clientTenant gewinnt),
|
|
55
|
+
// tenantExists validiert ihn. Spiegelt publicstatus (host-basierter
|
|
56
|
+
// tenantResolver, kein fixer default).
|
|
57
|
+
const resolver = createConfigResolver();
|
|
58
|
+
stack = await setupTestStack({
|
|
59
|
+
features: [configFeature, managed],
|
|
60
|
+
anonymousAccess: {
|
|
61
|
+
tenantExists: async (id) => id === TENANT_A || id === TENANT_B,
|
|
62
|
+
},
|
|
63
|
+
// Wire ctx.config() so the branding query resolves the (X-Tenant) tenant's
|
|
64
|
+
// branding cascade. Branding keys are not encrypted → no encryption needed.
|
|
65
|
+
extraContext: ({ registry }) => ({
|
|
66
|
+
configResolver: resolver,
|
|
67
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
await unsafeCreateEntityTable(stack.db, pageEntity);
|
|
71
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
72
|
+
await createEventsTable(stack.db);
|
|
73
|
+
|
|
74
|
+
await seedPage(stack.db, {
|
|
75
|
+
tenantId: TENANT_A,
|
|
76
|
+
slug: "about",
|
|
77
|
+
lang: "en",
|
|
78
|
+
title: "About A",
|
|
79
|
+
body: "# Hello from **A**",
|
|
80
|
+
published: true,
|
|
81
|
+
});
|
|
82
|
+
await seedPage(stack.db, {
|
|
83
|
+
tenantId: TENANT_A,
|
|
84
|
+
slug: "secret",
|
|
85
|
+
lang: "en",
|
|
86
|
+
title: "Secret A",
|
|
87
|
+
body: "draft only",
|
|
88
|
+
published: false,
|
|
89
|
+
});
|
|
90
|
+
await seedPage(stack.db, {
|
|
91
|
+
tenantId: TENANT_B,
|
|
92
|
+
slug: "about",
|
|
93
|
+
lang: "en",
|
|
94
|
+
title: "About B",
|
|
95
|
+
body: "# Hello from **B**",
|
|
96
|
+
published: true,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterAll(async () => {
|
|
101
|
+
await stack.cleanup();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("managed-pages :: server-render route", () => {
|
|
105
|
+
test("published Page → 200 mit gerendertem Markdown", async () => {
|
|
106
|
+
const res = await stack.app.request("http://a.example.com/p/about");
|
|
107
|
+
expect(res.status).toBe(200);
|
|
108
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
109
|
+
const html = await res.text();
|
|
110
|
+
expect(html).toContain("About A");
|
|
111
|
+
expect(html).toContain("<strong>A</strong>");
|
|
112
|
+
expect(html).toContain('lang="en"');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("Draft (unpublished) → 404 für anonyme Besucher", async () => {
|
|
116
|
+
const res = await stack.app.request("http://a.example.com/p/secret");
|
|
117
|
+
expect(res.status).toBe(404);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("unbekannter Slug → 404", async () => {
|
|
121
|
+
const res = await stack.app.request("http://a.example.com/p/nope");
|
|
122
|
+
expect(res.status).toBe(404);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("Host ohne Tenant (resolveApexTenant null) → 404", async () => {
|
|
126
|
+
const res = await stack.app.request("http://unknown.example.com/p/about");
|
|
127
|
+
expect(res.status).toBe(404);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("managed-pages :: Cross-Tenant-Isolation", () => {
|
|
132
|
+
test("derselbe Slug serviert pro Host den jeweiligen Tenant-Content", async () => {
|
|
133
|
+
const a = await (await stack.app.request("http://a.example.com/p/about")).text();
|
|
134
|
+
const b = await (await stack.app.request("http://b.example.com/p/about")).text();
|
|
135
|
+
expect(a).toContain("About A");
|
|
136
|
+
expect(a).not.toContain("About B");
|
|
137
|
+
expect(b).toContain("About B");
|
|
138
|
+
expect(b).not.toContain("About A");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("managed-pages :: Cache + Security-Header", () => {
|
|
143
|
+
test("Vary: Host + CSP/Hardening-Header", async () => {
|
|
144
|
+
const res = await stack.app.request("http://a.example.com/p/about");
|
|
145
|
+
expect(res.headers.get("vary")).toBe("Host");
|
|
146
|
+
expect(res.headers.get("cache-control")).toBe("public, max-age=300");
|
|
147
|
+
expect(res.headers.get("content-security-policy")).toContain("script-src 'none'");
|
|
148
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("managed-pages :: XSS-Härtung", () => {
|
|
153
|
+
test("<script> im Page-Body wird escaped", async () => {
|
|
154
|
+
await seedPage(stack.db, {
|
|
155
|
+
tenantId: TENANT_A,
|
|
156
|
+
slug: "xss",
|
|
157
|
+
lang: "en",
|
|
158
|
+
title: "XSS",
|
|
159
|
+
body: "## Test\n\n<script>window.x=1</script>\n\nok",
|
|
160
|
+
published: true,
|
|
161
|
+
});
|
|
162
|
+
const res = await stack.app.request("http://a.example.com/p/xss");
|
|
163
|
+
expect(res.status).toBe(200);
|
|
164
|
+
const html = await res.text();
|
|
165
|
+
expect(html).not.toContain("<script>window.x=1</script>");
|
|
166
|
+
expect(html).toContain("<script>");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("managed-pages :: Admin-Screens registriert", () => {
|
|
171
|
+
test("entityList + entityEdit Screens sind im Feature deklariert", () => {
|
|
172
|
+
const ids = Object.keys(managed.screens);
|
|
173
|
+
expect(ids).toContain("page-list");
|
|
174
|
+
expect(ids).toContain("page-edit");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("managed-pages :: Convention-CRUD (Admin-Authoring)", () => {
|
|
179
|
+
test("create → list → detail → update(publish) → public-read → delete", async () => {
|
|
180
|
+
await stack.http.writeOk(
|
|
181
|
+
"managed-pages:write:page:create",
|
|
182
|
+
{ slug: "crud", lang: "en", title: "CRUD", body: "# Body", published: false },
|
|
183
|
+
tenantAdmin,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// id robust über die Liste holen (unabhängig von der create-Return-Shape).
|
|
187
|
+
const list = await stack.http.queryOk<{ rows: Array<{ id: string; slug: string }> }>(
|
|
188
|
+
"managed-pages:query:page:list",
|
|
189
|
+
{},
|
|
190
|
+
tenantAdmin,
|
|
191
|
+
);
|
|
192
|
+
const row = list.rows.find((r) => r.slug === "crud");
|
|
193
|
+
expect(row).toBeTruthy();
|
|
194
|
+
const id = row!.id;
|
|
195
|
+
|
|
196
|
+
const detail = await stack.http.queryOk<{ title: string; published: boolean; version: number }>(
|
|
197
|
+
"managed-pages:query:page:detail",
|
|
198
|
+
{ id },
|
|
199
|
+
tenantAdmin,
|
|
200
|
+
);
|
|
201
|
+
expect(detail).toMatchObject({ title: "CRUD", published: false });
|
|
202
|
+
|
|
203
|
+
// Draft ist über die Public-Query (published-only) unsichtbar.
|
|
204
|
+
const draftRead = await stack.http.queryOk<unknown>(
|
|
205
|
+
"managed-pages:query:by-slug",
|
|
206
|
+
{ slug: "crud", lang: "en" },
|
|
207
|
+
tenantAdmin,
|
|
208
|
+
);
|
|
209
|
+
expect(draftRead).toBeFalsy();
|
|
210
|
+
|
|
211
|
+
// update: publish + Title ändern.
|
|
212
|
+
await stack.http.writeOk(
|
|
213
|
+
"managed-pages:write:page:update",
|
|
214
|
+
{ id, version: detail.version, changes: { published: true, title: "CRUD v2" } },
|
|
215
|
+
tenantAdmin,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// Public-Query liefert die Page jetzt (published) mit neuem Title.
|
|
219
|
+
const pubRead = await stack.http.queryOk<{ title: string }>(
|
|
220
|
+
"managed-pages:query:by-slug",
|
|
221
|
+
{ slug: "crud", lang: "en" },
|
|
222
|
+
tenantAdmin,
|
|
223
|
+
);
|
|
224
|
+
expect(pubRead).toMatchObject({ title: "CRUD v2" });
|
|
225
|
+
|
|
226
|
+
// delete → detail ist weg.
|
|
227
|
+
await stack.http.writeOk("managed-pages:write:page:delete", { id }, tenantAdmin);
|
|
228
|
+
const afterDelete = await stack.http.queryOk<unknown>(
|
|
229
|
+
"managed-pages:query:page:detail",
|
|
230
|
+
{ id },
|
|
231
|
+
tenantAdmin,
|
|
232
|
+
);
|
|
233
|
+
expect(afterDelete).toBeFalsy();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("normaler User darf nicht erstellen (access_denied)", async () => {
|
|
237
|
+
const error = await stack.http.writeErr(
|
|
238
|
+
"managed-pages:write:page:create",
|
|
239
|
+
{ slug: "denied", lang: "en", title: "x", body: null },
|
|
240
|
+
normalUser,
|
|
241
|
+
);
|
|
242
|
+
expectErrorIncludes(error, "access_denied");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("Cross-Tenant-Isolation: List zeigt nur eigene Pages", async () => {
|
|
246
|
+
await stack.http.writeOk(
|
|
247
|
+
"managed-pages:write:page:create",
|
|
248
|
+
{ slug: "tenant-a-only", lang: "en", title: "A only", body: "x", published: false },
|
|
249
|
+
tenantAdmin,
|
|
250
|
+
);
|
|
251
|
+
const otherList = await stack.http.queryOk<{ rows: Array<{ slug: string }> }>(
|
|
252
|
+
"managed-pages:query:page:list",
|
|
253
|
+
{},
|
|
254
|
+
otherAdmin,
|
|
255
|
+
);
|
|
256
|
+
expect(otherList.rows.some((r) => r.slug === "tenant-a-only")).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("managed-pages :: set (Provisioning-API)", () => {
|
|
261
|
+
test("idempotenter slug-keyed Upsert + preserve-on-omit (published)", async () => {
|
|
262
|
+
const first = await stack.http.writeOk<{ isNew: boolean }>(
|
|
263
|
+
"managed-pages:write:set",
|
|
264
|
+
{ slug: "prov", lang: "en", title: "Prov v1", body: "a", published: true },
|
|
265
|
+
tenantAdmin,
|
|
266
|
+
);
|
|
267
|
+
expect(first).toMatchObject({ isNew: true });
|
|
268
|
+
|
|
269
|
+
// Zweiter Call = Update (selber slug+lang); published bei Omit erhalten.
|
|
270
|
+
const second = await stack.http.writeOk<{ isNew: boolean }>(
|
|
271
|
+
"managed-pages:write:set",
|
|
272
|
+
{ slug: "prov", lang: "en", title: "Prov v2", body: "b" },
|
|
273
|
+
tenantAdmin,
|
|
274
|
+
);
|
|
275
|
+
expect(second).toMatchObject({ isNew: false });
|
|
276
|
+
|
|
277
|
+
// Beweis: published blieb true (preserve-on-omit) → Public-Query liefert
|
|
278
|
+
// die Page mit aktualisiertem Title.
|
|
279
|
+
const read = await stack.http.queryOk<{ title: string }>(
|
|
280
|
+
"managed-pages:query:by-slug",
|
|
281
|
+
{ slug: "prov", lang: "en" },
|
|
282
|
+
tenantAdmin,
|
|
283
|
+
);
|
|
284
|
+
expect(read).toMatchObject({ title: "Prov v2" });
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("managed-pages :: Branding (Config + Render)", () => {
|
|
289
|
+
// config:write:set leitet tenantId aus user.tenantId ab → tenant-spezifische
|
|
290
|
+
// Admins, damit das Branding auf TENANT_A bzw. TENANT_B landet (Host a.*/b.*).
|
|
291
|
+
const adminA = createTestUser({ id: 20, roles: ["TenantAdmin"], tenantId: TENANT_A });
|
|
292
|
+
const adminB = createTestUser({ id: 21, roles: ["TenantAdmin"], tenantId: TENANT_B });
|
|
293
|
+
|
|
294
|
+
test("configEdit-Screen branding-settings ist registriert", () => {
|
|
295
|
+
expect(Object.keys(managed.screens)).toContain("branding-settings");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("Custom-CSS-Key NICHT registriert ohne allowCustomCss (fail-closed-by-construction)", async () => {
|
|
299
|
+
// Default `managed` stack hat allowCustomCss=false → der branding-custom-css
|
|
300
|
+
// Key existiert nicht → ein Write darauf wird abgelehnt. Sperrt die
|
|
301
|
+
// Fail-closed-Eigenschaft gegen eine künftige "Key-immer-registrieren"-Regression.
|
|
302
|
+
const error = await stack.http.writeErr(
|
|
303
|
+
"config:write:set",
|
|
304
|
+
{ key: BRANDING_QN.customCss, value: ".x { color: red; }" },
|
|
305
|
+
adminA,
|
|
306
|
+
);
|
|
307
|
+
expect(error).toBeTruthy();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("valides Branding (Hex + https + Preset) wird gesetzt und im Render angewandt", async () => {
|
|
311
|
+
await stack.http.writeOk(
|
|
312
|
+
"config:write:set",
|
|
313
|
+
{ key: BRANDING_QN.accentColor, value: "#ff8800" },
|
|
314
|
+
adminA,
|
|
315
|
+
);
|
|
316
|
+
await stack.http.writeOk(
|
|
317
|
+
"config:write:set",
|
|
318
|
+
{ key: BRANDING_QN.logoUrl, value: "https://cdn-a.example.com/logo.png" },
|
|
319
|
+
adminA,
|
|
320
|
+
);
|
|
321
|
+
await stack.http.writeOk(
|
|
322
|
+
"config:write:set",
|
|
323
|
+
{ key: BRANDING_QN.title, value: "Acme A" },
|
|
324
|
+
adminA,
|
|
325
|
+
);
|
|
326
|
+
await stack.http.writeOk(
|
|
327
|
+
"config:write:set",
|
|
328
|
+
{ key: BRANDING_QN.layoutPreset, value: "wide" },
|
|
329
|
+
adminA,
|
|
330
|
+
);
|
|
331
|
+
await stack.http.writeOk(
|
|
332
|
+
"config:write:set",
|
|
333
|
+
{ key: BRANDING_QN.description, value: "Acme status and docs" },
|
|
334
|
+
adminA,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const html = await (await stack.app.request("http://a.example.com/p/about")).text();
|
|
338
|
+
// scoped :root-Override mit Accent + Preset-max-width
|
|
339
|
+
expect(html).toContain('<style id="tenant-theme">');
|
|
340
|
+
expect(html).toContain("--accent:#ff8800");
|
|
341
|
+
expect(html).toContain("--page-max-width:1100px");
|
|
342
|
+
// Logo + Titel im Branding-Header
|
|
343
|
+
expect(html).toContain('src="https://cdn-a.example.com/logo.png"');
|
|
344
|
+
expect(html).toContain("Acme A");
|
|
345
|
+
// branding-description als Site-Default-Meta (Seite "about" hat keine eigene)
|
|
346
|
+
expect(html).toContain('<meta name="description" content="Acme status and docs">');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("invalide Accent-Farbe (kein Hex, CSS-Injection-Versuch) → Write abgelehnt", async () => {
|
|
350
|
+
const error = await stack.http.writeErr(
|
|
351
|
+
"config:write:set",
|
|
352
|
+
{ key: BRANDING_QN.accentColor, value: "red; } body{display:none}" },
|
|
353
|
+
adminA,
|
|
354
|
+
);
|
|
355
|
+
expectErrorIncludes(error, "invalid_format");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("non-https Logo-URL → Write abgelehnt", async () => {
|
|
359
|
+
const error = await stack.http.writeErr(
|
|
360
|
+
"config:write:set",
|
|
361
|
+
{ key: BRANDING_QN.logoUrl, value: "http://insecure.example.com/logo.png" },
|
|
362
|
+
adminA,
|
|
363
|
+
);
|
|
364
|
+
expectErrorIncludes(error, "invalid_format");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("leerer Wert (clear) ist erlaubt — Pattern allow-empty", async () => {
|
|
368
|
+
await stack.http.writeOk(
|
|
369
|
+
"config:write:set",
|
|
370
|
+
{ key: BRANDING_QN.siteUrl, value: "https://acme.test" },
|
|
371
|
+
adminA,
|
|
372
|
+
);
|
|
373
|
+
await stack.http.writeOk("config:write:set", { key: BRANDING_QN.siteUrl, value: "" }, adminA);
|
|
374
|
+
const branding = await stack.http.queryOk<{ siteUrl: string }>(BRANDING_QUERY_QN, {}, adminA);
|
|
375
|
+
expect(branding.siteUrl).toBe("");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("über-langer Title (>200) → Write abgelehnt (Server-Längen-Cap)", async () => {
|
|
379
|
+
const error = await stack.http.writeErr(
|
|
380
|
+
"config:write:set",
|
|
381
|
+
{ key: BRANDING_QN.title, value: "x".repeat(201) },
|
|
382
|
+
adminA,
|
|
383
|
+
);
|
|
384
|
+
expectErrorIncludes(error, "invalid_format");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("Cross-Tenant-Isolation: TENANT_A-Branding leakt nicht auf TENANT_B", async () => {
|
|
388
|
+
await stack.http.writeOk(
|
|
389
|
+
"config:write:set",
|
|
390
|
+
{ key: BRANDING_QN.accentColor, value: "#0033cc" },
|
|
391
|
+
adminB,
|
|
392
|
+
);
|
|
393
|
+
const htmlB = await (await stack.app.request("http://b.example.com/p/about")).text();
|
|
394
|
+
expect(htmlB).toContain("--accent:#0033cc");
|
|
395
|
+
expect(htmlB).not.toContain("#ff8800");
|
|
396
|
+
expect(htmlB).not.toContain("cdn-a.example.com");
|
|
397
|
+
expect(htmlB).not.toContain("Acme A");
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Eigener Stack mit allowCustomCss:true + dem Companion-Toggle-Feature. Der
|
|
402
|
+
// per-Tenant-Gate wird über `effectiveFeatures` simuliert: TENANT_A hat
|
|
403
|
+
// `managed-pages-css` AN, TENANT_B AUS — gleiche App-Opt-in (allowCustomCss),
|
|
404
|
+
// unterschiedlicher Operator-/Tier-Toggle. Beweist End-to-End-Render + den
|
|
405
|
+
// Kill-Switch + dass der Render-Sanitizer Write-Bypass-Werte abfängt.
|
|
406
|
+
describe("managed-pages :: Custom CSS (gated, sanitized render)", () => {
|
|
407
|
+
let cssStack: TestStack;
|
|
408
|
+
const cssAdminA = createTestUser({ id: 30, roles: ["TenantAdmin"], tenantId: TENANT_A });
|
|
409
|
+
const cssAdminB = createTestUser({ id: 31, roles: ["TenantAdmin"], tenantId: TENANT_B });
|
|
410
|
+
|
|
411
|
+
const managedWithCss = createManagedPagesFeature({
|
|
412
|
+
resolveApexTenant: (host) => {
|
|
413
|
+
if (host.startsWith("a.")) return TENANT_A;
|
|
414
|
+
if (host.startsWith("b.")) return TENANT_B;
|
|
415
|
+
return null;
|
|
416
|
+
},
|
|
417
|
+
allowCustomCss: true,
|
|
418
|
+
});
|
|
419
|
+
const cssGate = createManagedPagesCssFeature();
|
|
420
|
+
const cssConfigFeature = createConfigFeature();
|
|
421
|
+
|
|
422
|
+
beforeAll(async () => {
|
|
423
|
+
const resolver = createConfigResolver();
|
|
424
|
+
cssStack = await setupTestStack({
|
|
425
|
+
features: [cssConfigFeature, managedWithCss, cssGate],
|
|
426
|
+
anonymousAccess: {
|
|
427
|
+
tenantExists: async (id) => id === TENANT_A || id === TENANT_B,
|
|
428
|
+
},
|
|
429
|
+
// Per-Tenant-Toggle: A enabled, B disabled.
|
|
430
|
+
effectiveFeatures: (tid) =>
|
|
431
|
+
tid === TENANT_A
|
|
432
|
+
? new Set(["config", "managed-pages", "managed-pages-css"])
|
|
433
|
+
: new Set(["config", "managed-pages"]),
|
|
434
|
+
extraContext: ({ registry }) => ({
|
|
435
|
+
configResolver: resolver,
|
|
436
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
437
|
+
}),
|
|
438
|
+
});
|
|
439
|
+
await unsafeCreateEntityTable(cssStack.db, pageEntity);
|
|
440
|
+
await unsafePushTables(cssStack.db, { configValuesTable });
|
|
441
|
+
await createEventsTable(cssStack.db);
|
|
442
|
+
await seedPage(cssStack.db, {
|
|
443
|
+
tenantId: TENANT_A,
|
|
444
|
+
slug: "about",
|
|
445
|
+
lang: "en",
|
|
446
|
+
title: "About A",
|
|
447
|
+
body: "# A",
|
|
448
|
+
published: true,
|
|
449
|
+
});
|
|
450
|
+
await seedPage(cssStack.db, {
|
|
451
|
+
tenantId: TENANT_B,
|
|
452
|
+
slug: "about",
|
|
453
|
+
lang: "en",
|
|
454
|
+
title: "About B",
|
|
455
|
+
body: "# B",
|
|
456
|
+
published: true,
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
afterAll(async () => {
|
|
461
|
+
await cssStack.cleanup();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("custom-css Config-Key ist registriert wenn allowCustomCss (Write ok)", async () => {
|
|
465
|
+
await cssStack.http.writeOk(
|
|
466
|
+
"config:write:set",
|
|
467
|
+
{ key: BRANDING_QN.customCss, value: ".note { color: red; }" },
|
|
468
|
+
cssAdminA,
|
|
469
|
+
);
|
|
470
|
+
// Gate AN → die branding-Query gibt den (rohen) customCss zurück; beweist
|
|
471
|
+
// dass der Key registriert ist UND der Wert persistiert (nicht nur „kein
|
|
472
|
+
// Write-Fehler").
|
|
473
|
+
const branding = await cssStack.http.queryOk<{ customCss?: string }>(
|
|
474
|
+
BRANDING_QUERY_QN,
|
|
475
|
+
{},
|
|
476
|
+
cssAdminA,
|
|
477
|
+
);
|
|
478
|
+
expect(branding.customCss).toContain(".note");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("Gate AN: Tenant-CSS gescoped in <style data-tenant-css> gerendert", async () => {
|
|
482
|
+
await cssStack.http.writeOk(
|
|
483
|
+
"config:write:set",
|
|
484
|
+
{ key: BRANDING_QN.customCss, value: ".note { color: rebeccapurple; }" },
|
|
485
|
+
cssAdminA,
|
|
486
|
+
);
|
|
487
|
+
const html = await (await cssStack.app.request("http://a.example.com/p/about")).text();
|
|
488
|
+
expect(html).toContain("<style data-tenant-css>");
|
|
489
|
+
expect(html).toContain("[data-tenant-content] .note");
|
|
490
|
+
expect(html).toContain("color: rebeccapurple");
|
|
491
|
+
expect(html).toContain("<main data-tenant-content>");
|
|
492
|
+
// full containment (position/isolation + overflow clip) ships in the
|
|
493
|
+
// tenant-css block, only alongside tenant CSS — boxes + clips tenant paint
|
|
494
|
+
// off host chrome.
|
|
495
|
+
expect(html).toContain("[data-tenant-content]{position:relative;isolation:isolate}");
|
|
496
|
+
expect(html).toContain("[data-tenant-content]{overflow:hidden}");
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("gespeichertes Angriffs-CSS wird am Render sanitized (Write-Gate-Bypass-Abwehr)", async () => {
|
|
500
|
+
// Der Längen-Cap-Pattern lässt das speichern; der Render-Sanitizer ist der
|
|
501
|
+
// eigentliche Allowlist-Gate. Jeder Vektor einzeln exerziert.
|
|
502
|
+
const attack =
|
|
503
|
+
".evil { position: fixed; top: 0; } .ok { color: red; } @import url('http://evil.test');";
|
|
504
|
+
await cssStack.http.writeOk(
|
|
505
|
+
"config:write:set",
|
|
506
|
+
{ key: BRANDING_QN.customCss, value: attack },
|
|
507
|
+
cssAdminA,
|
|
508
|
+
);
|
|
509
|
+
const html = await (await cssStack.app.request("http://a.example.com/p/about")).text();
|
|
510
|
+
expect(html).not.toContain("position: fixed");
|
|
511
|
+
expect(html).not.toContain("@import");
|
|
512
|
+
expect(html).not.toContain("evil.test");
|
|
513
|
+
// die eine sichere Regel überlebt, gescoped
|
|
514
|
+
expect(html).toContain("[data-tenant-content] .ok");
|
|
515
|
+
expect(html).toContain("color: red");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("Gate AUS (Toggle off für Tenant B): kein Tenant-CSS trotz gespeichertem Wert", async () => {
|
|
519
|
+
await cssStack.http.writeOk(
|
|
520
|
+
"config:write:set",
|
|
521
|
+
{ key: BRANDING_QN.customCss, value: ".note { color: red; }" },
|
|
522
|
+
cssAdminB,
|
|
523
|
+
);
|
|
524
|
+
const html = await (await cssStack.app.request("http://b.example.com/p/about")).text();
|
|
525
|
+
expect(html).not.toContain("<style data-tenant-css>");
|
|
526
|
+
expect(html).not.toContain("[data-tenant-content] .note");
|
|
527
|
+
// no tenant CSS → no clip (plain pages keep normal overflow for wide content)
|
|
528
|
+
expect(html).not.toContain("overflow:hidden");
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("configEdit-Screen zeigt das customCss-Feld wenn allowCustomCss", () => {
|
|
532
|
+
const screen = managedWithCss.screens["branding-settings"];
|
|
533
|
+
expect(screen).toBeTruthy();
|
|
534
|
+
expect(JSON.stringify(screen)).toContain("branding-custom-css");
|
|
535
|
+
});
|
|
536
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConfigAccessor,
|
|
3
|
+
type ConfigKeyDefinition,
|
|
4
|
+
createTenantConfig,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { type BrandingTokens, EMPTY_BRANDING } from "../page-render";
|
|
7
|
+
|
|
8
|
+
// Per-tenant branding, lifted from the publicstatus-local branding-config
|
|
9
|
+
// pattern into the framework so every app + studio-tenant gets tenant-editable
|
|
10
|
+
// branding. Stored as `config` keys (scope: tenant), edited via the
|
|
11
|
+
// configEdit screen (screens/branding-screen.ts), read at render time via the
|
|
12
|
+
// branding query.
|
|
13
|
+
//
|
|
14
|
+
// Write-validated: accent-color must be a CSS hex, logo/site URLs must be
|
|
15
|
+
// https. The configEdit screen dispatches `config:write:set` per key, which
|
|
16
|
+
// runs the keyDef.pattern gate (set.write.ts → validatePattern). `read: all`
|
|
17
|
+
// (scope default) so the anonymous public-render path can read them;
|
|
18
|
+
// `write: admin` (TenantAdmin/Admin/SystemAdmin, also the scope default).
|
|
19
|
+
//
|
|
20
|
+
// CONTINUITY (Phase 5 — Prod trap): these keys land under
|
|
21
|
+
// `managed-pages:config:branding-*`, NOT the live `publicstatus:config:
|
|
22
|
+
// branding-*`. The publicstatus consumer (Phase 5) MUST migrate the existing
|
|
23
|
+
// read_config_values rows to the new QNs per tenant BEFORE switching the read
|
|
24
|
+
// path, or every tenant resets to defaults on deploy. The migration is
|
|
25
|
+
// deliberately NOT built here — the source QNs belong to the consumer app.
|
|
26
|
+
|
|
27
|
+
// Anchored, allow-empty (empty = "unset, use default"; cleared via the form).
|
|
28
|
+
// Linear → ReDoS-safe on untrusted tenant input. The URL char-class mirrors
|
|
29
|
+
// the render-side validator (page-render/branding.ts) so a value that saves
|
|
30
|
+
// also renders. text keys carry an explicit length cap too — `validateType`
|
|
31
|
+
// only checks the JS type, and the configEdit `maxLength` is client-side, so a
|
|
32
|
+
// direct config:write:set would otherwise be unbounded (the page body is
|
|
33
|
+
// likewise capped in Phase 2).
|
|
34
|
+
const HEX_PATTERN = {
|
|
35
|
+
regex: "^$|^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
|
|
36
|
+
} as const;
|
|
37
|
+
const HTTPS_PATTERN = { regex: "^$|^https://[^\\s\"'<>]{1,2000}$" } as const;
|
|
38
|
+
const TITLE_PATTERN = { regex: "^[\\s\\S]{0,200}$" } as const;
|
|
39
|
+
const DESCRIPTION_PATTERN = { regex: "^[\\s\\S]{0,500}$" } as const;
|
|
40
|
+
// Custom CSS is too complex for a regex allowlist — the write-time pattern only
|
|
41
|
+
// caps length (ReDoS-safe, allow-empty); the real allowlist sanitization runs at
|
|
42
|
+
// render (page-render/css-sanitize.ts). Keep this cap in sync with MAX_CSS_LENGTH
|
|
43
|
+
// there.
|
|
44
|
+
const CUSTOM_CSS_PATTERN = { regex: "^[\\s\\S]{0,8000}$" } as const;
|
|
45
|
+
|
|
46
|
+
export const LAYOUT_PRESETS = ["minimal", "centered", "wide"] as const;
|
|
47
|
+
|
|
48
|
+
// Companion toggle feature name. managed-pages reads ctx.hasFeature() against it
|
|
49
|
+
// in the branding query to per-tenant-gate custom-CSS emission. Declared as a
|
|
50
|
+
// `r.toggleable({default:false})` feature in css-gate.ts.
|
|
51
|
+
export const MANAGED_PAGES_CSS_FEATURE = "managed-pages-css";
|
|
52
|
+
|
|
53
|
+
// Short keys → qualified names via define-feature's `qn(feature, "config",
|
|
54
|
+
// toKebab(key))`: e.g. `brandingSiteUrl` → `managed-pages:config:branding-
|
|
55
|
+
// site-url`. BRANDING_QN below is the single source those QN strings are read
|
|
56
|
+
// from (configEdit screen + render read); the integration test pins write-
|
|
57
|
+
// target == read-source end-to-end.
|
|
58
|
+
export const BRANDING_KEYS = {
|
|
59
|
+
brandingTitle: createTenantConfig("text", { default: "", pattern: TITLE_PATTERN }),
|
|
60
|
+
brandingDescription: createTenantConfig("text", { default: "", pattern: DESCRIPTION_PATTERN }),
|
|
61
|
+
brandingSiteUrl: createTenantConfig("text", { default: "", pattern: HTTPS_PATTERN }),
|
|
62
|
+
brandingAccentColor: createTenantConfig("text", { default: "", pattern: HEX_PATTERN }),
|
|
63
|
+
brandingLogoUrl: createTenantConfig("text", { default: "", pattern: HTTPS_PATTERN }),
|
|
64
|
+
brandingLayoutPreset: createTenantConfig("select", {
|
|
65
|
+
default: "centered",
|
|
66
|
+
options: LAYOUT_PRESETS,
|
|
67
|
+
}),
|
|
68
|
+
} satisfies Record<string, ConfigKeyDefinition>;
|
|
69
|
+
|
|
70
|
+
// Registered ONLY when the app passes allowCustomCss:true — kept out of
|
|
71
|
+
// BRANDING_KEYS so the CSS editor field + key don't exist when the capability is
|
|
72
|
+
// off (fail-closed at the app level). The render-time sanitizer is the real
|
|
73
|
+
// safety boundary; this is the opt-in switch + a per-tenant toggle gate.
|
|
74
|
+
export const CUSTOM_CSS_KEY = {
|
|
75
|
+
brandingCustomCss: createTenantConfig("text", { default: "", pattern: CUSTOM_CSS_PATTERN }),
|
|
76
|
+
} satisfies Record<string, ConfigKeyDefinition>;
|
|
77
|
+
|
|
78
|
+
export const BRANDING_QN = {
|
|
79
|
+
title: "managed-pages:config:branding-title",
|
|
80
|
+
description: "managed-pages:config:branding-description",
|
|
81
|
+
siteUrl: "managed-pages:config:branding-site-url",
|
|
82
|
+
accentColor: "managed-pages:config:branding-accent-color",
|
|
83
|
+
logoUrl: "managed-pages:config:branding-logo-url",
|
|
84
|
+
layoutPreset: "managed-pages:config:branding-layout-preset",
|
|
85
|
+
customCss: "managed-pages:config:branding-custom-css",
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
export const BRANDING_QUERY_QN = "managed-pages:query:branding";
|
|
89
|
+
|
|
90
|
+
async function readText(config: ConfigAccessor, qualifiedKey: string): Promise<string> {
|
|
91
|
+
const value = await config(qualifiedKey);
|
|
92
|
+
return typeof value === "string" ? value : "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Resolve the tenant's branding cascade into render tokens. `config` is
|
|
96
|
+
// optional because ctx.config is only wired when the app composes the config
|
|
97
|
+
// feature — a missing accessor degrades to defaults (same posture as
|
|
98
|
+
// publicstatus' branding read), not a crash.
|
|
99
|
+
export async function readBranding(config: ConfigAccessor | undefined): Promise<BrandingTokens> {
|
|
100
|
+
if (!config) return EMPTY_BRANDING;
|
|
101
|
+
const [title, description, siteUrl, accentColor, logoUrl, layoutPreset] = await Promise.all([
|
|
102
|
+
readText(config, BRANDING_QN.title),
|
|
103
|
+
readText(config, BRANDING_QN.description),
|
|
104
|
+
readText(config, BRANDING_QN.siteUrl),
|
|
105
|
+
readText(config, BRANDING_QN.accentColor),
|
|
106
|
+
readText(config, BRANDING_QN.logoUrl),
|
|
107
|
+
readText(config, BRANDING_QN.layoutPreset),
|
|
108
|
+
]);
|
|
109
|
+
// customCss stays "" here — it is gated (allowCustomCss + per-tenant toggle)
|
|
110
|
+
// and read separately by the branding query via readCustomCss.
|
|
111
|
+
return { title, description, siteUrl, accentColor, logoUrl, layoutPreset, customCss: "" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Read the raw, untrusted custom CSS for the request's tenant. The caller (the
|
|
115
|
+
// branding query) only invokes this once the gate has passed; the value is
|
|
116
|
+
// sanitized at render (page-render), never trusted here.
|
|
117
|
+
export async function readCustomCss(config: ConfigAccessor): Promise<string> {
|
|
118
|
+
return readText(config, BRANDING_QN.customCss);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function stringField(source: Record<string, unknown>, key: string): string {
|
|
122
|
+
const value = source[key];
|
|
123
|
+
return typeof value === "string" ? value : "";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Coerce the branding query's wire response (`{ data: <BrandingTokens> }`)
|
|
127
|
+
// into BrandingTokens at the IO boundary, without an `as` cast — any missing/
|
|
128
|
+
// non-string field falls back to "" so a malformed/empty response renders the
|
|
129
|
+
// unbranded default rather than throwing.
|
|
130
|
+
export function coerceBranding(value: unknown): BrandingTokens {
|
|
131
|
+
if (typeof value !== "object" || value === null) return EMPTY_BRANDING;
|
|
132
|
+
const source = Object.fromEntries(Object.entries(value));
|
|
133
|
+
return {
|
|
134
|
+
title: stringField(source, "title"),
|
|
135
|
+
description: stringField(source, "description"),
|
|
136
|
+
siteUrl: stringField(source, "siteUrl"),
|
|
137
|
+
accentColor: stringField(source, "accentColor"),
|
|
138
|
+
logoUrl: stringField(source, "logoUrl"),
|
|
139
|
+
layoutPreset: stringField(source, "layoutPreset"),
|
|
140
|
+
customCss: stringField(source, "customCss"),
|
|
141
|
+
};
|
|
142
|
+
}
|