@setzkasten-cms/astro-admin 1.4.2 → 1.5.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/dist/api-routes/_auth-guard.d.ts +27 -3
- package/dist/api-routes/_auth-guard.js +5 -2
- package/dist/api-routes/_dev-session-secret.d.ts +8 -0
- package/dist/api-routes/_dev-session-secret.js +8 -0
- package/dist/api-routes/_github-token.js +1 -1
- package/dist/api-routes/_role-resolver.js +6 -3
- package/dist/api-routes/_session-secret.d.ts +19 -0
- package/dist/api-routes/_session-secret.js +7 -0
- package/dist/api-routes/_session-signing.d.ts +45 -0
- package/dist/api-routes/_session-signing.js +8 -0
- package/dist/api-routes/_webhook-dispatcher.js +4 -4
- package/dist/api-routes/asset-proxy.js +1 -1
- package/dist/api-routes/auth-callback.js +12 -5
- package/dist/api-routes/auth-logout.d.ts +4 -4
- package/dist/api-routes/auth-logout.js +8 -2
- package/dist/api-routes/auth-session.d.ts +6 -0
- package/dist/api-routes/auth-session.js +19 -19
- package/dist/api-routes/auth-setzkasten-login.js +14 -7
- package/dist/api-routes/catalog-add.js +59 -17
- package/dist/api-routes/catalog-export.js +14 -4
- package/dist/api-routes/config.d.ts +10 -3
- package/dist/api-routes/config.js +26 -4
- package/dist/api-routes/deploy-hook.js +8 -8
- package/dist/api-routes/editors.d.ts +1 -1
- package/dist/api-routes/editors.js +5 -2
- package/dist/api-routes/github-proxy.js +30 -8
- package/dist/api-routes/global-config.js +6 -3
- package/dist/api-routes/history-rollback.js +31 -14
- package/dist/api-routes/history-version.js +8 -6
- package/dist/api-routes/history.js +5 -2
- package/dist/api-routes/icons-local.js +1 -1
- package/dist/api-routes/init-add-section.js +150 -48
- package/dist/api-routes/init-apply.js +56 -42
- package/dist/api-routes/init-migrate.js +43 -36
- package/dist/api-routes/init-scan-page.d.ts +1 -1
- package/dist/api-routes/init-scan-page.js +59 -13
- package/dist/api-routes/init-scan.js +22 -7
- package/dist/api-routes/migrate-to-multi.js +5 -2
- package/dist/api-routes/pages.js +15 -4
- package/dist/api-routes/section-add.js +68 -16
- package/dist/api-routes/section-commit-pending.js +70 -22
- package/dist/api-routes/section-delete.js +49 -14
- package/dist/api-routes/section-duplicate.js +65 -16
- package/dist/api-routes/section-prepare-copy.js +15 -2
- package/dist/api-routes/section-prepare.js +25 -4
- package/dist/api-routes/setup-github-app-bounce.js +15 -1
- package/dist/api-routes/setup-github-app-branches.js +9 -6
- package/dist/api-routes/setup-github-app-callback.js +24 -1
- package/dist/api-routes/setup-github-app-credentials.d.ts +27 -0
- package/dist/api-routes/setup-github-app-credentials.js +43 -0
- package/dist/api-routes/setup-github-app-installed.js +22 -1
- package/dist/api-routes/setup-github-app-repos.js +5 -2
- package/dist/api-routes/setup-github-app.d.ts +4 -0
- package/dist/api-routes/setup-github-app.js +19 -2
- package/dist/api-routes/updater-register.js +7 -1
- package/dist/api-routes/webhooks-status.js +5 -2
- package/dist/api-routes/webhooks-test.js +9 -8
- package/dist/api-routes/webhooks.js +12 -14
- package/dist/api-routes/websites-add.js +5 -2
- package/dist/api-routes/websites-remove.js +5 -2
- package/dist/{chunk-ZQDGGWJP.js → chunk-5KMGSFCZ.js} +2 -2
- package/dist/{chunk-RHJONMLK.js → chunk-CDXCYYQR.js} +222 -5
- package/dist/{chunk-NKDATSPA.js → chunk-DP6RTINQ.js} +1 -1
- package/dist/chunk-KENFINT4.js +76 -0
- package/dist/chunk-ONP6BRZO.js +47 -0
- package/dist/{chunk-INIWFKQ3.js → chunk-Q5HV47DW.js} +33 -19
- package/dist/chunk-QVCW6EF3.js +26 -0
- package/dist/{chunk-K22A4ZBS.js → chunk-UHI6323G.js} +293 -174
- package/dist/{chunk-AM4DZXXM.js → chunk-UJAFZEX2.js} +76 -9
- package/package.json +12 -6
- package/src/api-routes/__tests__/_session-signing.test.ts +114 -0
- package/src/api-routes/__tests__/_session-test-helper.ts +27 -0
- package/src/api-routes/__tests__/add-section-helpers.test.ts +91 -97
- package/src/api-routes/__tests__/auth-guard.test.ts +46 -7
- package/src/api-routes/__tests__/catalog-api.test.ts +14 -6
- package/src/api-routes/__tests__/commit-trailers.test.ts +5 -5
- package/src/api-routes/__tests__/deferred-operations.test.ts +9 -12
- package/src/api-routes/__tests__/deploy-hook.test.ts +3 -8
- package/src/api-routes/__tests__/feature-gate.test.ts +1 -1
- package/src/api-routes/__tests__/github-cache.test.ts +1 -1
- package/src/api-routes/__tests__/github-token.test.ts +1 -1
- package/src/api-routes/__tests__/global-config-theme.test.ts +4 -4
- package/src/api-routes/__tests__/history-rollback.test.ts +6 -3
- package/src/api-routes/__tests__/history.test.ts +9 -6
- package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +11 -3
- package/src/api-routes/__tests__/migrate-to-multi.test.ts +5 -1
- package/src/api-routes/__tests__/pages-meta-store.test.ts +10 -5
- package/src/api-routes/__tests__/pages.test.ts +7 -2
- package/src/api-routes/__tests__/patch-page-file.test.ts +71 -19
- package/src/api-routes/__tests__/route-registry.test.ts +11 -18
- package/src/api-routes/__tests__/scan-page-helpers.test.ts +13 -10
- package/src/api-routes/__tests__/section-management.test.ts +28 -28
- package/src/api-routes/__tests__/setup-github-app-callback.test.ts +58 -16
- package/src/api-routes/__tests__/setup-github-app-repos.test.ts +4 -5
- package/src/api-routes/__tests__/setup-github-app.test.ts +27 -7
- package/src/api-routes/__tests__/storage-config-for-request.test.ts +83 -0
- package/src/api-routes/__tests__/updater-register.test.ts +230 -0
- package/src/api-routes/__tests__/webhook-signing.test.ts +1 -1
- package/src/api-routes/__tests__/webhooks.test.ts +19 -7
- package/src/api-routes/__tests__/websites-add.test.ts +2 -1
- package/src/api-routes/__tests__/websites-remove.test.ts +2 -1
- package/src/api-routes/_auth-guard.ts +47 -15
- package/src/api-routes/_commit-trailers.ts +3 -2
- package/src/api-routes/_dev-session-secret.ts +79 -0
- package/src/api-routes/_github-token.ts +1 -1
- package/src/api-routes/_pages-meta-store.ts +2 -2
- package/src/api-routes/_role-resolver.ts +7 -5
- package/src/api-routes/_session-secret.ts +46 -0
- package/src/api-routes/_session-signing.ts +135 -0
- package/src/api-routes/_vercel-origin.ts +2 -6
- package/src/api-routes/_webhook-dispatcher.ts +12 -16
- package/src/api-routes/_website-resolver.ts +3 -10
- package/src/api-routes/auth-callback.ts +9 -5
- package/src/api-routes/auth-login.ts +5 -3
- package/src/api-routes/auth-logout.ts +18 -1
- package/src/api-routes/auth-session.ts +13 -21
- package/src/api-routes/auth-setzkasten-login.ts +12 -9
- package/src/api-routes/catalog-add.ts +89 -31
- package/src/api-routes/catalog-export.ts +30 -10
- package/src/api-routes/config.ts +39 -6
- package/src/api-routes/deploy-hook.ts +13 -11
- package/src/api-routes/editors.ts +33 -22
- package/src/api-routes/github-proxy.ts +25 -11
- package/src/api-routes/global-config.ts +103 -18
- package/src/api-routes/history-rollback.ts +41 -14
- package/src/api-routes/history-version.ts +5 -6
- package/src/api-routes/history.ts +3 -3
- package/src/api-routes/icons-local.ts +2 -2
- package/src/api-routes/init-add-section.ts +218 -88
- package/src/api-routes/init-apply.ts +71 -56
- package/src/api-routes/init-migrate.ts +54 -48
- package/src/api-routes/init-scan-page.ts +77 -30
- package/src/api-routes/init-scan.ts +19 -11
- package/src/api-routes/pages.ts +16 -11
- package/src/api-routes/section-add.ts +98 -27
- package/src/api-routes/section-commit-pending.ts +87 -34
- package/src/api-routes/section-delete.ts +76 -27
- package/src/api-routes/section-duplicate.ts +95 -28
- package/src/api-routes/section-management.ts +3 -7
- package/src/api-routes/section-prepare-copy.ts +29 -8
- package/src/api-routes/section-prepare.ts +38 -10
- package/src/api-routes/setup-github-app-bounce.ts +7 -1
- package/src/api-routes/setup-github-app-branches.ts +6 -7
- package/src/api-routes/setup-github-app-callback.ts +18 -1
- package/src/api-routes/setup-github-app-credentials.ts +55 -0
- package/src/api-routes/setup-github-app-installed.ts +12 -1
- package/src/api-routes/setup-github-app-repos.ts +2 -3
- package/src/api-routes/setup-github-app.ts +14 -5
- package/src/api-routes/updater-check.ts +6 -4
- package/src/api-routes/updater-register.ts +34 -20
- package/src/api-routes/updater-transfer.ts +8 -6
- package/src/api-routes/updater-unbind.ts +14 -10
- package/src/api-routes/webhooks-test.ts +9 -11
- package/src/api-routes/webhooks.ts +15 -19
- package/src/init/__tests__/page-level.test.ts +279 -105
- package/src/init/__tests__/page-list-coverage.test.ts +70 -70
- package/src/init/__tests__/patcher-child-component.test.ts +126 -0
- package/src/init/__tests__/patcher-edge-cases.test.ts +47 -23
- package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +16 -6
- package/src/init/__tests__/patcher-page-mode.test.ts +30 -20
- package/src/init/__tests__/section-pipeline.test.ts +102 -16
- package/src/init/astro-config-patcher.ts +4 -18
- package/src/init/astro-detector.ts +2 -7
- package/src/init/astro-section-analyzer-v2.ts +475 -193
- package/src/init/field-label-enricher.ts +6 -6
- package/src/init/template-patcher-v2.ts +490 -56
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
parseSession
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-Q5HV47DW.js";
|
|
4
4
|
import {
|
|
5
5
|
resolveStorageConfig
|
|
6
6
|
} from "./chunk-6UIKVKED.js";
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./chunk-45ARVNT3.js";
|
|
11
11
|
import {
|
|
12
12
|
resolveConfigRepoToken
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-DP6RTINQ.js";
|
|
14
14
|
import {
|
|
15
15
|
withTrailers
|
|
16
16
|
} from "./chunk-KH22FJO5.js";
|
|
@@ -27,12 +27,17 @@ var PUT = async ({ request, cookies }) => {
|
|
|
27
27
|
const session = parseSession(cookies.get("setzkasten_session")?.value);
|
|
28
28
|
if (!session) return new Response("Unauthorized", { status: 401 });
|
|
29
29
|
if (session.user.role !== "admin") return new Response("Forbidden", { status: 403 });
|
|
30
|
-
let
|
|
30
|
+
let raw;
|
|
31
31
|
try {
|
|
32
|
-
|
|
32
|
+
raw = await request.json();
|
|
33
33
|
} catch {
|
|
34
34
|
return Response.json({ error: "Invalid request body" }, { status: 400 });
|
|
35
35
|
}
|
|
36
|
+
const validated = validateGlobalConfigPatch(raw);
|
|
37
|
+
if (!validated.ok) {
|
|
38
|
+
return Response.json({ error: validated.error }, { status: 400 });
|
|
39
|
+
}
|
|
40
|
+
const patch = validated.value;
|
|
36
41
|
const current = await readGlobalConfig() ?? {};
|
|
37
42
|
const next = { ...current };
|
|
38
43
|
for (const [k, v] of Object.entries(patch)) {
|
|
@@ -42,6 +47,61 @@ var PUT = async ({ request, cookies }) => {
|
|
|
42
47
|
await writeGlobalConfig(next);
|
|
43
48
|
return Response.json({ ok: true });
|
|
44
49
|
};
|
|
50
|
+
function validateGlobalConfigPatch(input) {
|
|
51
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
52
|
+
return { ok: false, error: "Body must be a JSON object" };
|
|
53
|
+
}
|
|
54
|
+
const o = input;
|
|
55
|
+
const out = {};
|
|
56
|
+
if ("firebaseConfig" in o) {
|
|
57
|
+
if (o.firebaseConfig === null) {
|
|
58
|
+
;
|
|
59
|
+
out.firebaseConfig = null;
|
|
60
|
+
} else {
|
|
61
|
+
if (!o.firebaseConfig || typeof o.firebaseConfig !== "object") {
|
|
62
|
+
return { ok: false, error: "firebaseConfig must be an object or null" };
|
|
63
|
+
}
|
|
64
|
+
const fc = o.firebaseConfig;
|
|
65
|
+
for (const k of ["apiKey", "authDomain", "projectId"]) {
|
|
66
|
+
if (typeof fc[k] !== "string" || !fc[k]) {
|
|
67
|
+
return { ok: false, error: `firebaseConfig.${k} must be a non-empty string` };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
out.firebaseConfig = {
|
|
71
|
+
apiKey: fc.apiKey,
|
|
72
|
+
authDomain: fc.authDomain,
|
|
73
|
+
projectId: fc.projectId
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if ("theme" in o) {
|
|
78
|
+
if (o.theme === null) {
|
|
79
|
+
;
|
|
80
|
+
out.theme = null;
|
|
81
|
+
} else {
|
|
82
|
+
if (!o.theme || typeof o.theme !== "object") {
|
|
83
|
+
return { ok: false, error: "theme must be an object or null" };
|
|
84
|
+
}
|
|
85
|
+
const t = o.theme;
|
|
86
|
+
const theme = {};
|
|
87
|
+
for (const k of ["primaryColor", "brandName", "logo"]) {
|
|
88
|
+
if (k in t) {
|
|
89
|
+
if (typeof t[k] !== "string") {
|
|
90
|
+
return { ok: false, error: `theme.${k} must be a string` };
|
|
91
|
+
}
|
|
92
|
+
theme[k] = t[k];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
out.theme = theme;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const k of Object.keys(o)) {
|
|
99
|
+
if (k !== "firebaseConfig" && k !== "theme") {
|
|
100
|
+
return { ok: false, error: `unknown top-level field: ${k}` };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, value: out };
|
|
104
|
+
}
|
|
45
105
|
async function getStorageParams() {
|
|
46
106
|
const serverConfig = globalThis.__SETZKASTEN_CONFIG__;
|
|
47
107
|
const storage = resolveStorageConfig();
|
|
@@ -64,7 +124,13 @@ async function readGlobalConfig() {
|
|
|
64
124
|
return cachedFetch(key, 5 * 6e4, async () => {
|
|
65
125
|
const res = await fetch(
|
|
66
126
|
`https://api.github.com/repos/${owner}/${repo}/contents/${GLOBAL_CONFIG_FILE(contentPath)}?ref=${branch}`,
|
|
67
|
-
{
|
|
127
|
+
{
|
|
128
|
+
headers: {
|
|
129
|
+
Authorization: `Bearer ${token}`,
|
|
130
|
+
Accept: "application/vnd.github+json",
|
|
131
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
132
|
+
}
|
|
133
|
+
}
|
|
68
134
|
);
|
|
69
135
|
if (!res.ok) return null;
|
|
70
136
|
const data = await res.json();
|
|
@@ -102,10 +168,11 @@ async function writeGlobalConfig(config) {
|
|
|
102
168
|
branch
|
|
103
169
|
};
|
|
104
170
|
if (sha) body.sha = sha;
|
|
105
|
-
const res = await fetch(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
171
|
+
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`, {
|
|
172
|
+
method: "PUT",
|
|
173
|
+
headers,
|
|
174
|
+
body: JSON.stringify(body)
|
|
175
|
+
});
|
|
109
176
|
if (!res.ok) {
|
|
110
177
|
const text = await res.text();
|
|
111
178
|
throw new Error(`GitHub write failed: ${text}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@setzkasten-cms/astro-admin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Setzkasten Admin-UI, Init-Wizard und Adoptions-Pipeline für Astro",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -226,6 +226,11 @@
|
|
|
226
226
|
"import": "./dist/api-routes/setup-github-app-bounce.js",
|
|
227
227
|
"default": "./dist/api-routes/setup-github-app-bounce.js"
|
|
228
228
|
},
|
|
229
|
+
"./setup-github-app-credentials": {
|
|
230
|
+
"types": "./dist/api-routes/setup-github-app-credentials.d.ts",
|
|
231
|
+
"import": "./dist/api-routes/setup-github-app-credentials.js",
|
|
232
|
+
"default": "./dist/api-routes/setup-github-app-credentials.js"
|
|
233
|
+
},
|
|
229
234
|
"./setup-github-app-repos": {
|
|
230
235
|
"types": "./dist/api-routes/setup-github-app-repos.d.ts",
|
|
231
236
|
"import": "./dist/api-routes/setup-github-app-repos.js",
|
|
@@ -270,11 +275,11 @@
|
|
|
270
275
|
},
|
|
271
276
|
"dependencies": {
|
|
272
277
|
"@astrojs/compiler": "^3.0.0",
|
|
273
|
-
"@setzkasten-cms/auth": "1.
|
|
274
|
-
"@setzkasten-cms/
|
|
275
|
-
"@setzkasten-cms/
|
|
276
|
-
"@setzkasten-cms/github-adapter": "1.
|
|
277
|
-
"@setzkasten-cms/ui": "1.
|
|
278
|
+
"@setzkasten-cms/auth": "1.5.0",
|
|
279
|
+
"@setzkasten-cms/core": "1.5.0",
|
|
280
|
+
"@setzkasten-cms/catalog": "1.5.0",
|
|
281
|
+
"@setzkasten-cms/github-adapter": "1.5.0",
|
|
282
|
+
"@setzkasten-cms/ui": "1.5.0"
|
|
278
283
|
},
|
|
279
284
|
"peerDependencies": {
|
|
280
285
|
"astro": "^5.0.0",
|
|
@@ -285,6 +290,7 @@
|
|
|
285
290
|
"build": "tsup",
|
|
286
291
|
"typecheck": "tsc --noEmit",
|
|
287
292
|
"test": "vitest run",
|
|
293
|
+
"coverage": "vitest run --coverage",
|
|
288
294
|
"test:watch": "vitest"
|
|
289
295
|
}
|
|
290
296
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { AuthSession } from '@setzkasten-cms/core'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { signSession, verifySessionCookie } from '../_session-signing'
|
|
4
|
+
|
|
5
|
+
const SECRET = 'test-secret-do-not-use-in-production-32bytes-min'
|
|
6
|
+
|
|
7
|
+
const VALID_SESSION: AuthSession = {
|
|
8
|
+
user: {
|
|
9
|
+
id: 'u-1',
|
|
10
|
+
email: 'a@example.com',
|
|
11
|
+
name: 'A',
|
|
12
|
+
provider: 'github',
|
|
13
|
+
role: 'admin',
|
|
14
|
+
},
|
|
15
|
+
expiresAt: Date.now() + 60_000,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('signSession + verifySessionCookie', () => {
|
|
19
|
+
it('round-trips a valid payload', () => {
|
|
20
|
+
const cookie = signSession(VALID_SESSION, SECRET)
|
|
21
|
+
const result = verifySessionCookie(cookie, SECRET)
|
|
22
|
+
expect(result.ok).toBe(true)
|
|
23
|
+
if (result.ok) {
|
|
24
|
+
expect(result.value.user.email).toBe('a@example.com')
|
|
25
|
+
expect(result.value.user.role).toBe('admin')
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('rejects a tampered payload (role escalation attempt)', () => {
|
|
30
|
+
const cookie = signSession(
|
|
31
|
+
{ ...VALID_SESSION, user: { ...VALID_SESSION.user, role: 'editor' } },
|
|
32
|
+
SECRET,
|
|
33
|
+
)
|
|
34
|
+
const [, sig] = cookie.split('.')
|
|
35
|
+
// Re-encode the payload with role: 'admin' but keep the original signature.
|
|
36
|
+
const forgedPayload = Buffer.from(
|
|
37
|
+
JSON.stringify({ ...VALID_SESSION, user: { ...VALID_SESSION.user, role: 'admin' } }),
|
|
38
|
+
).toString('base64url')
|
|
39
|
+
const forged = `${forgedPayload}.${sig}`
|
|
40
|
+
|
|
41
|
+
const result = verifySessionCookie(forged, SECRET)
|
|
42
|
+
expect(result.ok).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('rejects a tampered signature', () => {
|
|
46
|
+
const cookie = signSession(VALID_SESSION, SECRET)
|
|
47
|
+
const [payload] = cookie.split('.')
|
|
48
|
+
const result = verifySessionCookie(`${payload}.AAAAAAAAAAAA`, SECRET)
|
|
49
|
+
expect(result.ok).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('rejects an expired session', () => {
|
|
53
|
+
const expired: AuthSession = { ...VALID_SESSION, expiresAt: Date.now() - 1 }
|
|
54
|
+
const cookie = signSession(expired, SECRET)
|
|
55
|
+
const result = verifySessionCookie(cookie, SECRET)
|
|
56
|
+
expect(result.ok).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('rejects a session with expiresAt in the distant future (max-age cap)', () => {
|
|
60
|
+
// An attacker who got an old cookie can't push the expiry forever.
|
|
61
|
+
// Honest: we don't enforce a server-side max age (besides cookie
|
|
62
|
+
// maxAge), but the signing layer must at minimum require a numeric
|
|
63
|
+
// expiresAt — guard against missing/bogus types.
|
|
64
|
+
const noExpiry = { ...VALID_SESSION, expiresAt: undefined as unknown as number }
|
|
65
|
+
const cookie = signSession(noExpiry, SECRET)
|
|
66
|
+
const result = verifySessionCookie(cookie, SECRET)
|
|
67
|
+
expect(result.ok).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('rejects a cookie signed with a different secret', () => {
|
|
71
|
+
const cookie = signSession(VALID_SESSION, SECRET)
|
|
72
|
+
const result = verifySessionCookie(cookie, 'different-secret-still-32-bytes-or-longer')
|
|
73
|
+
expect(result.ok).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('rejects an unsigned plain JSON cookie (legacy format)', () => {
|
|
77
|
+
// Pre-C1 cookies were `JSON.stringify(session)`. Those must not pass
|
|
78
|
+
// verification — users get logged out and re-auth, which is correct.
|
|
79
|
+
const legacy = JSON.stringify(VALID_SESSION)
|
|
80
|
+
const result = verifySessionCookie(legacy, SECRET)
|
|
81
|
+
expect(result.ok).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('rejects malformed cookies', () => {
|
|
85
|
+
expect(verifySessionCookie('', SECRET).ok).toBe(false)
|
|
86
|
+
expect(verifySessionCookie('only-one-segment', SECRET).ok).toBe(false)
|
|
87
|
+
expect(verifySessionCookie('a.b.c', SECRET).ok).toBe(false)
|
|
88
|
+
expect(verifySessionCookie('!.!', SECRET).ok).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('uses constant-time comparison (no early exit on length mismatch)', () => {
|
|
92
|
+
// We can't directly assert timing, but we can assert that the API
|
|
93
|
+
// doesn't throw on signature length mismatch — a naïve `===` on
|
|
94
|
+
// buffers would. This guards against accidental refactor regressions.
|
|
95
|
+
const cookie = signSession(VALID_SESSION, SECRET)
|
|
96
|
+
const [payload] = cookie.split('.')
|
|
97
|
+
expect(verifySessionCookie(`${payload}.x`, SECRET).ok).toBe(false)
|
|
98
|
+
expect(verifySessionCookie(`${payload}.${'A'.repeat(200)}`, SECRET).ok).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('refuses to sign without a secret', () => {
|
|
102
|
+
expect(() => signSession(VALID_SESSION, '')).toThrow()
|
|
103
|
+
expect(() => signSession(VALID_SESSION, ' ')).toThrow()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('respects a custom `now` value for testing (deterministic expiry)', () => {
|
|
107
|
+
const cookie = signSession(VALID_SESSION, SECRET)
|
|
108
|
+
// 1 ms before expiry: ok. 1 ms after: rejected.
|
|
109
|
+
const ok = verifySessionCookie(cookie, SECRET, VALID_SESSION.expiresAt - 1)
|
|
110
|
+
const expired = verifySessionCookie(cookie, SECRET, VALID_SESSION.expiresAt + 1)
|
|
111
|
+
expect(ok.ok).toBe(true)
|
|
112
|
+
expect(expired.ok).toBe(false)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only helper to mint signed session cookies. Pre-C1 tests used
|
|
3
|
+
* `JSON.stringify` and expected `parseSession` to accept it — that's
|
|
4
|
+
* exactly the forgery surface the production code now rejects.
|
|
5
|
+
*
|
|
6
|
+
* On import, this module pins `SETZKASTEN_SESSION_SECRET` so that the
|
|
7
|
+
* production resolver returns the same secret the helper signs with.
|
|
8
|
+
* No filesystem touch (avoids creating .setzkasten/dev-secret during
|
|
9
|
+
* test runs).
|
|
10
|
+
*/
|
|
11
|
+
import type { AuthSession } from '@setzkasten-cms/core'
|
|
12
|
+
import { __setDevSessionSecretForTests } from '../_dev-session-secret'
|
|
13
|
+
import { signSession } from '../_session-signing'
|
|
14
|
+
|
|
15
|
+
const TEST_SECRET = 'sk-test-fixed-secret-32-bytes-min-vitest-deterministic-runs-v1'
|
|
16
|
+
|
|
17
|
+
// Env var is the first lookup in the resolver — set it once on module
|
|
18
|
+
// load so all callers, including ones that don't import this helper
|
|
19
|
+
// directly but transitively touch parseSession, see the same value.
|
|
20
|
+
process.env.SETZKASTEN_SESSION_SECRET = TEST_SECRET
|
|
21
|
+
// Also seed the dev cache so the file-based fallback never triggers
|
|
22
|
+
// even if the env var is unset in some test that resets globals.
|
|
23
|
+
__setDevSessionSecretForTests(TEST_SECRET)
|
|
24
|
+
|
|
25
|
+
export function makeTestSessionCookie(session: AuthSession): string {
|
|
26
|
+
return signSession(session, TEST_SECRET)
|
|
27
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* 6. calculateRelativePath: correct relative paths for various depths
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { describe,
|
|
17
|
+
import { describe, expect, it } from 'vitest'
|
|
18
18
|
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
// Helpers under test — re-implemented inline so we can test them without
|
|
@@ -26,15 +26,24 @@ import { describe, it, expect } from 'vitest'
|
|
|
26
26
|
|
|
27
27
|
function getDefaultValue(fieldType: string): unknown {
|
|
28
28
|
switch (fieldType) {
|
|
29
|
-
case 'text':
|
|
30
|
-
|
|
31
|
-
case '
|
|
32
|
-
|
|
33
|
-
case '
|
|
34
|
-
|
|
35
|
-
case '
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
case 'text':
|
|
30
|
+
return ''
|
|
31
|
+
case 'number':
|
|
32
|
+
return 0
|
|
33
|
+
case 'boolean':
|
|
34
|
+
return false
|
|
35
|
+
case 'image':
|
|
36
|
+
return { path: '', alt: '' }
|
|
37
|
+
case 'array':
|
|
38
|
+
return []
|
|
39
|
+
case 'color':
|
|
40
|
+
return '#000000'
|
|
41
|
+
case 'date':
|
|
42
|
+
return ''
|
|
43
|
+
case 'icon':
|
|
44
|
+
return ''
|
|
45
|
+
default:
|
|
46
|
+
return ''
|
|
38
47
|
}
|
|
39
48
|
}
|
|
40
49
|
|
|
@@ -106,12 +115,10 @@ function buildPageConfig(
|
|
|
106
115
|
return { sections: [{ key: sectionKey, enabled: true }] }
|
|
107
116
|
}
|
|
108
117
|
|
|
109
|
-
/** Build
|
|
110
|
-
function buildPreviewClone(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
.replace(/(from\s+')(\.\.\/)/g, '$1../$2')
|
|
114
|
-
.replace(/(from\s+")(\.\.\/)/g, '$1../$2')
|
|
118
|
+
/** Build a thin SSR wrapper that imports the production page as a component */
|
|
119
|
+
function buildPreviewClone(relativePage: string): string {
|
|
120
|
+
const importDepth = '../'.repeat(relativePage.split('/').length)
|
|
121
|
+
return `---\nexport const prerender = false;\nimport Page from '${importDepth}${relativePage}';\n---\n<Page />\n`
|
|
115
122
|
}
|
|
116
123
|
|
|
117
124
|
// ---------------------------------------------------------------------------
|
|
@@ -153,20 +160,32 @@ describe('Content JSON generation', () => {
|
|
|
153
160
|
]
|
|
154
161
|
|
|
155
162
|
it('should use defaultValue when provided', () => {
|
|
156
|
-
const data = buildSectionData(
|
|
163
|
+
const data = buildSectionData(
|
|
164
|
+
{},
|
|
165
|
+
fields,
|
|
166
|
+
fields.map((f) => f.key),
|
|
167
|
+
)
|
|
157
168
|
expect(data.heading).toBe('Hello')
|
|
158
169
|
expect(data.count).toBe(42)
|
|
159
170
|
expect(data.items).toEqual(['a', 'b'])
|
|
160
171
|
})
|
|
161
172
|
|
|
162
173
|
it('should fall back to getDefaultValue when defaultValue is absent', () => {
|
|
163
|
-
const data = buildSectionData(
|
|
174
|
+
const data = buildSectionData(
|
|
175
|
+
{},
|
|
176
|
+
fields,
|
|
177
|
+
fields.map((f) => f.key),
|
|
178
|
+
)
|
|
164
179
|
expect(data.active).toBe(false)
|
|
165
180
|
})
|
|
166
181
|
|
|
167
182
|
it('should not overwrite existing values on re-adoption', () => {
|
|
168
183
|
const existing = { heading: 'Existing Title', count: 99 }
|
|
169
|
-
const data = buildSectionData(
|
|
184
|
+
const data = buildSectionData(
|
|
185
|
+
existing,
|
|
186
|
+
fields,
|
|
187
|
+
fields.map((f) => f.key),
|
|
188
|
+
)
|
|
170
189
|
expect(data.heading).toBe('Existing Title')
|
|
171
190
|
expect(data.count).toBe(99)
|
|
172
191
|
})
|
|
@@ -225,50 +244,35 @@ describe('Page config — section deduplication', () => {
|
|
|
225
244
|
})
|
|
226
245
|
|
|
227
246
|
// ---------------------------------------------------------------------------
|
|
228
|
-
// 4. sk-preview clone —
|
|
247
|
+
// 4. sk-preview clone — thin wrapper with correct import depth
|
|
229
248
|
// ---------------------------------------------------------------------------
|
|
230
249
|
|
|
231
250
|
describe('sk-preview clone generation', () => {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
---
|
|
238
|
-
|
|
239
|
-
<BaseLayout>
|
|
240
|
-
<section id="section-_page_docs_architecture">
|
|
241
|
-
<h1 set:html={skData?.heading ?? 'Architektur'} />
|
|
242
|
-
</section>
|
|
243
|
-
</BaseLayout>
|
|
244
|
-
`
|
|
245
|
-
|
|
246
|
-
it('should remove the prerender export', () => {
|
|
247
|
-
const clone = buildPreviewClone(patched)
|
|
248
|
-
expect(clone).not.toContain('export const prerender')
|
|
251
|
+
it('top-level page: imports with one ../', () => {
|
|
252
|
+
const clone = buildPreviewClone('impressum.astro')
|
|
253
|
+
expect(clone).toBe(
|
|
254
|
+
"---\nexport const prerender = false;\nimport Page from '../impressum.astro';\n---\n<Page />\n",
|
|
255
|
+
)
|
|
249
256
|
})
|
|
250
257
|
|
|
251
|
-
it('
|
|
252
|
-
const clone = buildPreviewClone(
|
|
253
|
-
|
|
254
|
-
// After fix: '../../../layouts/BaseLayout.astro'
|
|
255
|
-
expect(clone).toContain("from '../../../layouts/BaseLayout.astro'")
|
|
258
|
+
it('nested page: imports with correct depth', () => {
|
|
259
|
+
const clone = buildPreviewClone('docs/index.astro')
|
|
260
|
+
expect(clone).toContain("import Page from '../../docs/index.astro'")
|
|
256
261
|
})
|
|
257
262
|
|
|
258
|
-
it('
|
|
259
|
-
const clone = buildPreviewClone(
|
|
260
|
-
expect(clone).toContain("from '
|
|
263
|
+
it('three-level page: imports with three ../', () => {
|
|
264
|
+
const clone = buildPreviewClone('docs/api/reference.astro')
|
|
265
|
+
expect(clone).toContain("import Page from '../../../docs/api/reference.astro'")
|
|
261
266
|
})
|
|
262
267
|
|
|
263
|
-
it('
|
|
264
|
-
const clone = buildPreviewClone(
|
|
265
|
-
expect(clone).toContain(
|
|
268
|
+
it('always has export const prerender = false', () => {
|
|
269
|
+
const clone = buildPreviewClone('impressum.astro')
|
|
270
|
+
expect(clone).toContain('export const prerender = false')
|
|
266
271
|
})
|
|
267
272
|
|
|
268
|
-
it('
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
expect(clone).toContain('../../../layouts/BaseLayout.astro')
|
|
273
|
+
it('renders as <Page />', () => {
|
|
274
|
+
const clone = buildPreviewClone('impressum.astro')
|
|
275
|
+
expect(clone).toContain('<Page />')
|
|
272
276
|
})
|
|
273
277
|
})
|
|
274
278
|
|
|
@@ -278,28 +282,36 @@ const skData = getSection('_page_docs_architecture')
|
|
|
278
282
|
|
|
279
283
|
describe('calculateRelativePath', () => {
|
|
280
284
|
it('same directory', () => {
|
|
281
|
-
expect(calculateRelativePath('src/components', 'src/components/Hero.astro'))
|
|
282
|
-
|
|
285
|
+
expect(calculateRelativePath('src/components', 'src/components/Hero.astro')).toBe(
|
|
286
|
+
'./Hero.astro',
|
|
287
|
+
)
|
|
283
288
|
})
|
|
284
289
|
|
|
285
290
|
it('one level up', () => {
|
|
286
|
-
expect(calculateRelativePath('src/pages', 'src/components/sections/HeroSection.astro'))
|
|
287
|
-
|
|
291
|
+
expect(calculateRelativePath('src/pages', 'src/components/sections/HeroSection.astro')).toBe(
|
|
292
|
+
'../components/sections/HeroSection.astro',
|
|
293
|
+
)
|
|
288
294
|
})
|
|
289
295
|
|
|
290
296
|
it('two levels up', () => {
|
|
291
|
-
expect(
|
|
292
|
-
|
|
297
|
+
expect(
|
|
298
|
+
calculateRelativePath('src/pages/docs', 'src/components/sections/HeroSection.astro'),
|
|
299
|
+
).toBe('../../components/sections/HeroSection.astro')
|
|
293
300
|
})
|
|
294
301
|
|
|
295
302
|
it('completely different paths', () => {
|
|
296
|
-
expect(
|
|
297
|
-
|
|
303
|
+
expect(
|
|
304
|
+
calculateRelativePath(
|
|
305
|
+
'apps/website/src/pages',
|
|
306
|
+
'apps/website/src/components/sections/Footer.astro',
|
|
307
|
+
),
|
|
308
|
+
).toBe('../components/sections/Footer.astro')
|
|
298
309
|
})
|
|
299
310
|
|
|
300
311
|
it('component in same folder as page', () => {
|
|
301
|
-
expect(calculateRelativePath('src/pages', 'src/pages/HeroSection.astro'))
|
|
302
|
-
|
|
312
|
+
expect(calculateRelativePath('src/pages', 'src/pages/HeroSection.astro')).toBe(
|
|
313
|
+
'./HeroSection.astro',
|
|
314
|
+
)
|
|
303
315
|
})
|
|
304
316
|
})
|
|
305
317
|
|
|
@@ -309,16 +321,14 @@ describe('calculateRelativePath', () => {
|
|
|
309
321
|
// When the UI sends pagePath='src/pages/docs.astro' but the actual file is
|
|
310
322
|
// src/pages/docs/index.astro (directory route), the sk-preview clone must
|
|
311
323
|
// be placed at sk-preview/docs/index.astro (NOT sk-preview/docs.astro).
|
|
312
|
-
//
|
|
313
|
-
// sk-preview/docs.astro (wrong): same depth as src/pages/sk-preview/
|
|
314
|
-
// → buildPreviewClone adds "../" → "../../layouts/" becomes "../../../layouts/" ✗
|
|
315
|
-
//
|
|
316
|
-
// sk-preview/docs/index.astro (correct): one level deeper in sk-preview/docs/
|
|
317
|
-
// → buildPreviewClone adds "../" → "../../layouts/" becomes "../../../layouts/" ✓
|
|
324
|
+
// The import depth must match: docs/index.astro → '../../docs/index.astro'.
|
|
318
325
|
// ---------------------------------------------------------------------------
|
|
319
326
|
|
|
320
327
|
/** Simulate resolvedPagePath logic from init-add-section.ts step 4 */
|
|
321
|
-
function resolvePagePath(
|
|
328
|
+
function resolvePagePath(
|
|
329
|
+
bodyPagePath: string,
|
|
330
|
+
fileExistsOnGitHub: (path: string) => boolean,
|
|
331
|
+
): string {
|
|
322
332
|
if (fileExistsOnGitHub(bodyPagePath)) return bodyPagePath
|
|
323
333
|
if (bodyPagePath.endsWith('.astro')) {
|
|
324
334
|
const indexPath = bodyPagePath.replace(/\.astro$/, '/index.astro')
|
|
@@ -334,7 +344,6 @@ describe('Directory-route clone path (build-failure regression)', () => {
|
|
|
334
344
|
})
|
|
335
345
|
|
|
336
346
|
it('directory route: resolvedPagePath falls back to index.astro', () => {
|
|
337
|
-
// docs.astro does not exist, docs/index.astro does
|
|
338
347
|
const resolved = resolvePagePath(
|
|
339
348
|
'src/pages/docs.astro',
|
|
340
349
|
(p) => p === 'src/pages/docs/index.astro',
|
|
@@ -342,42 +351,27 @@ describe('Directory-route clone path (build-failure regression)', () => {
|
|
|
342
351
|
expect(resolved).toBe('src/pages/docs/index.astro')
|
|
343
352
|
})
|
|
344
353
|
|
|
345
|
-
it('directory route:
|
|
346
|
-
const bodyPagePath = 'src/pages/docs.astro'
|
|
354
|
+
it('directory route: relativePage uses resolved path, not body path', () => {
|
|
347
355
|
const resolved = resolvePagePath(
|
|
348
|
-
|
|
356
|
+
'src/pages/docs.astro',
|
|
349
357
|
(p) => p === 'src/pages/docs/index.astro',
|
|
350
358
|
)
|
|
351
359
|
const relativePage = resolved.replace(/^src\/pages\//, '')
|
|
352
|
-
// Must be 'docs/index.astro', NOT 'docs.astro'
|
|
353
360
|
expect(relativePage).toBe('docs/index.astro')
|
|
354
361
|
expect(relativePage).not.toBe('docs.astro')
|
|
355
362
|
})
|
|
356
363
|
|
|
357
|
-
it('directory route clone
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const source = `---
|
|
361
|
-
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
362
|
-
import { getSection } from 'setzkasten:content'
|
|
363
|
-
const skData = getSection('_page_docs')
|
|
364
|
-
---
|
|
365
|
-
<BaseLayout><slot /></BaseLayout>
|
|
366
|
-
`
|
|
367
|
-
const clone = buildPreviewClone(source)
|
|
368
|
-
expect(clone).toContain("from '../../../layouts/BaseLayout.astro'")
|
|
364
|
+
it('directory route: clone imports from correct depth', () => {
|
|
365
|
+
const clone = buildPreviewClone('docs/index.astro')
|
|
366
|
+
expect(clone).toContain("import Page from '../../docs/index.astro'")
|
|
369
367
|
})
|
|
370
368
|
|
|
371
|
-
it('wrong
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
//
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
expect(wrongRelativePage).toBe('docs.astro')
|
|
379
|
-
// The CORRECT relative page (after fix):
|
|
380
|
-
const correctRelativePage = 'src/pages/docs/index.astro'.replace(/^src\/pages\//, '')
|
|
381
|
-
expect(correctRelativePage).toBe('docs/index.astro')
|
|
369
|
+
it('wrong relativePage (docs.astro) produces shallow import depth', () => {
|
|
370
|
+
// Documents why resolved path matters: wrong path → wrong import
|
|
371
|
+
const wrongClone = buildPreviewClone('docs.astro')
|
|
372
|
+
expect(wrongClone).toContain("import Page from '../docs.astro'")
|
|
373
|
+
// vs correct:
|
|
374
|
+
const correctClone = buildPreviewClone('docs/index.astro')
|
|
375
|
+
expect(correctClone).toContain("import Page from '../../docs/index.astro'")
|
|
382
376
|
})
|
|
383
377
|
})
|