@setzkasten-cms/astro-admin 1.4.6 → 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 +113 -47
- 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-Q3N336KR.js → chunk-CDXCYYQR.js} +29 -24
- 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-TD76R3A6.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 +59 -25
- 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 +174 -79
- 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 +12 -3
- 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 +53 -19
- 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 +218 -97
|
@@ -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
|
|
|
@@ -151,20 +160,32 @@ describe('Content JSON generation', () => {
|
|
|
151
160
|
]
|
|
152
161
|
|
|
153
162
|
it('should use defaultValue when provided', () => {
|
|
154
|
-
const data = buildSectionData(
|
|
163
|
+
const data = buildSectionData(
|
|
164
|
+
{},
|
|
165
|
+
fields,
|
|
166
|
+
fields.map((f) => f.key),
|
|
167
|
+
)
|
|
155
168
|
expect(data.heading).toBe('Hello')
|
|
156
169
|
expect(data.count).toBe(42)
|
|
157
170
|
expect(data.items).toEqual(['a', 'b'])
|
|
158
171
|
})
|
|
159
172
|
|
|
160
173
|
it('should fall back to getDefaultValue when defaultValue is absent', () => {
|
|
161
|
-
const data = buildSectionData(
|
|
174
|
+
const data = buildSectionData(
|
|
175
|
+
{},
|
|
176
|
+
fields,
|
|
177
|
+
fields.map((f) => f.key),
|
|
178
|
+
)
|
|
162
179
|
expect(data.active).toBe(false)
|
|
163
180
|
})
|
|
164
181
|
|
|
165
182
|
it('should not overwrite existing values on re-adoption', () => {
|
|
166
183
|
const existing = { heading: 'Existing Title', count: 99 }
|
|
167
|
-
const data = buildSectionData(
|
|
184
|
+
const data = buildSectionData(
|
|
185
|
+
existing,
|
|
186
|
+
fields,
|
|
187
|
+
fields.map((f) => f.key),
|
|
188
|
+
)
|
|
168
189
|
expect(data.heading).toBe('Existing Title')
|
|
169
190
|
expect(data.count).toBe(99)
|
|
170
191
|
})
|
|
@@ -229,7 +250,9 @@ describe('Page config — section deduplication', () => {
|
|
|
229
250
|
describe('sk-preview clone generation', () => {
|
|
230
251
|
it('top-level page: imports with one ../', () => {
|
|
231
252
|
const clone = buildPreviewClone('impressum.astro')
|
|
232
|
-
expect(clone).toBe(
|
|
253
|
+
expect(clone).toBe(
|
|
254
|
+
"---\nexport const prerender = false;\nimport Page from '../impressum.astro';\n---\n<Page />\n",
|
|
255
|
+
)
|
|
233
256
|
})
|
|
234
257
|
|
|
235
258
|
it('nested page: imports with correct depth', () => {
|
|
@@ -259,28 +282,36 @@ describe('sk-preview clone generation', () => {
|
|
|
259
282
|
|
|
260
283
|
describe('calculateRelativePath', () => {
|
|
261
284
|
it('same directory', () => {
|
|
262
|
-
expect(calculateRelativePath('src/components', 'src/components/Hero.astro'))
|
|
263
|
-
|
|
285
|
+
expect(calculateRelativePath('src/components', 'src/components/Hero.astro')).toBe(
|
|
286
|
+
'./Hero.astro',
|
|
287
|
+
)
|
|
264
288
|
})
|
|
265
289
|
|
|
266
290
|
it('one level up', () => {
|
|
267
|
-
expect(calculateRelativePath('src/pages', 'src/components/sections/HeroSection.astro'))
|
|
268
|
-
|
|
291
|
+
expect(calculateRelativePath('src/pages', 'src/components/sections/HeroSection.astro')).toBe(
|
|
292
|
+
'../components/sections/HeroSection.astro',
|
|
293
|
+
)
|
|
269
294
|
})
|
|
270
295
|
|
|
271
296
|
it('two levels up', () => {
|
|
272
|
-
expect(
|
|
273
|
-
|
|
297
|
+
expect(
|
|
298
|
+
calculateRelativePath('src/pages/docs', 'src/components/sections/HeroSection.astro'),
|
|
299
|
+
).toBe('../../components/sections/HeroSection.astro')
|
|
274
300
|
})
|
|
275
301
|
|
|
276
302
|
it('completely different paths', () => {
|
|
277
|
-
expect(
|
|
278
|
-
|
|
303
|
+
expect(
|
|
304
|
+
calculateRelativePath(
|
|
305
|
+
'apps/website/src/pages',
|
|
306
|
+
'apps/website/src/components/sections/Footer.astro',
|
|
307
|
+
),
|
|
308
|
+
).toBe('../components/sections/Footer.astro')
|
|
279
309
|
})
|
|
280
310
|
|
|
281
311
|
it('component in same folder as page', () => {
|
|
282
|
-
expect(calculateRelativePath('src/pages', 'src/pages/HeroSection.astro'))
|
|
283
|
-
|
|
312
|
+
expect(calculateRelativePath('src/pages', 'src/pages/HeroSection.astro')).toBe(
|
|
313
|
+
'./HeroSection.astro',
|
|
314
|
+
)
|
|
284
315
|
})
|
|
285
316
|
})
|
|
286
317
|
|
|
@@ -294,7 +325,10 @@ describe('calculateRelativePath', () => {
|
|
|
294
325
|
// ---------------------------------------------------------------------------
|
|
295
326
|
|
|
296
327
|
/** Simulate resolvedPagePath logic from init-add-section.ts step 4 */
|
|
297
|
-
function resolvePagePath(
|
|
328
|
+
function resolvePagePath(
|
|
329
|
+
bodyPagePath: string,
|
|
330
|
+
fileExistsOnGitHub: (path: string) => boolean,
|
|
331
|
+
): string {
|
|
298
332
|
if (fileExistsOnGitHub(bodyPagePath)) return bodyPagePath
|
|
299
333
|
if (bodyPagePath.endsWith('.astro')) {
|
|
300
334
|
const indexPath = bodyPagePath.replace(/\.astro$/, '/index.astro')
|
|
@@ -1,17 +1,24 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
-
import { parseSession, guardPageAccess } from '../_auth-guard'
|
|
3
1
|
import type { AuthSession, SetzKastenConfig } from '@setzkasten-cms/core'
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { guardPageAccess, parseSession } from '../_auth-guard'
|
|
4
|
+
import { makeTestSessionCookie } from './_session-test-helper'
|
|
4
5
|
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Helpers
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
|
|
9
10
|
function adminSession(): AuthSession {
|
|
10
|
-
return {
|
|
11
|
+
return {
|
|
12
|
+
user: { id: '1', email: 'admin@example.com', provider: 'github', role: 'admin' },
|
|
13
|
+
expiresAt: Date.now() + 86400_000,
|
|
14
|
+
}
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
function editorSession(email = 'editor@example.com'): AuthSession {
|
|
14
|
-
return {
|
|
18
|
+
return {
|
|
19
|
+
user: { id: '2', email, provider: 'google', role: 'editor' },
|
|
20
|
+
expiresAt: Date.now() + 86400_000,
|
|
21
|
+
}
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
const baseConfig: SetzKastenConfig = {
|
|
@@ -33,9 +40,37 @@ describe('parseSession', () => {
|
|
|
33
40
|
expect(parseSession('not-json')).toBeNull()
|
|
34
41
|
})
|
|
35
42
|
|
|
36
|
-
it('
|
|
43
|
+
it('rejects an unsigned plain-JSON cookie (legacy / forged format)', () => {
|
|
44
|
+
// Pre-C1 the cookie was JSON.stringify(session) — anyone could forge
|
|
45
|
+
// any role. parseSession must refuse the old format outright.
|
|
37
46
|
const session = adminSession()
|
|
38
|
-
expect(parseSession(JSON.stringify(session))).
|
|
47
|
+
expect(parseSession(JSON.stringify(session))).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('accepts a properly signed session cookie', () => {
|
|
51
|
+
const session = adminSession()
|
|
52
|
+
expect(parseSession(makeTestSessionCookie(session))).toEqual(session)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('rejects an expired signed session', () => {
|
|
56
|
+
const expired: AuthSession = {
|
|
57
|
+
user: { id: '1', email: 'admin@example.com', provider: 'github', role: 'admin' },
|
|
58
|
+
expiresAt: Date.now() - 1,
|
|
59
|
+
}
|
|
60
|
+
expect(parseSession(makeTestSessionCookie(expired))).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('rejects a tampered payload (role escalation forgery)', () => {
|
|
64
|
+
const editor: AuthSession = {
|
|
65
|
+
user: { id: '2', email: 'e@example.com', provider: 'google', role: 'editor' },
|
|
66
|
+
expiresAt: Date.now() + 60_000,
|
|
67
|
+
}
|
|
68
|
+
const cookie = makeTestSessionCookie(editor)
|
|
69
|
+
const [, sig] = cookie.split('.')
|
|
70
|
+
const forgedPayload = Buffer.from(
|
|
71
|
+
JSON.stringify({ ...editor, user: { ...editor.user, role: 'admin' } }),
|
|
72
|
+
).toString('base64url')
|
|
73
|
+
expect(parseSession(`${forgedPayload}.${sig}`)).toBeNull()
|
|
39
74
|
})
|
|
40
75
|
})
|
|
41
76
|
|
|
@@ -103,7 +138,11 @@ describe('guardPageAccess – dynamic editors', () => {
|
|
|
103
138
|
kind: 'present',
|
|
104
139
|
editors: [{ email: 'editor@example.com' }],
|
|
105
140
|
})
|
|
106
|
-
const res = await guardPageAccess(
|
|
141
|
+
const res = await guardPageAccess(
|
|
142
|
+
editorSession('editor@example.com'),
|
|
143
|
+
'secret-page',
|
|
144
|
+
baseConfig,
|
|
145
|
+
)
|
|
107
146
|
expect(res).toBeNull()
|
|
108
147
|
})
|
|
109
148
|
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
* 3. buildCatalogAddCommit — builds file paths for a catalog add commit
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { describe,
|
|
13
|
+
import { describe, expect, it } from 'vitest'
|
|
14
14
|
import {
|
|
15
|
+
buildCatalogAddCommit,
|
|
15
16
|
buildCatalogResponse,
|
|
16
17
|
validateCatalogAddBody,
|
|
17
|
-
buildCatalogAddCommit,
|
|
18
18
|
} from '../catalog-helpers'
|
|
19
19
|
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
@@ -31,7 +31,7 @@ describe('buildCatalogResponse', () => {
|
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
it('includes hero, features, cta', () => {
|
|
34
|
-
const names = buildCatalogResponse().map(t => t.name)
|
|
34
|
+
const names = buildCatalogResponse().map((t) => t.name)
|
|
35
35
|
expect(names).toContain('hero')
|
|
36
36
|
expect(names).toContain('features')
|
|
37
37
|
expect(names).toContain('cta')
|
|
@@ -62,7 +62,9 @@ describe('validateCatalogAddBody', () => {
|
|
|
62
62
|
})
|
|
63
63
|
|
|
64
64
|
it('accepts body with sectionKey override', () => {
|
|
65
|
-
expect(() =>
|
|
65
|
+
expect(() =>
|
|
66
|
+
validateCatalogAddBody({ templateName: 'hero', pageKey: 'index', sectionKey: 'hero--top' }),
|
|
67
|
+
).not.toThrow()
|
|
66
68
|
})
|
|
67
69
|
|
|
68
70
|
it('throws when templateName is missing', () => {
|
|
@@ -74,7 +76,9 @@ describe('validateCatalogAddBody', () => {
|
|
|
74
76
|
})
|
|
75
77
|
|
|
76
78
|
it('throws when templateName is not in registry', () => {
|
|
77
|
-
expect(() =>
|
|
79
|
+
expect(() =>
|
|
80
|
+
validateCatalogAddBody({ templateName: 'nonexistent-xyz', pageKey: 'index' }),
|
|
81
|
+
).toThrow()
|
|
78
82
|
})
|
|
79
83
|
})
|
|
80
84
|
|
|
@@ -109,7 +113,11 @@ describe('buildCatalogAddCommit', () => {
|
|
|
109
113
|
})
|
|
110
114
|
|
|
111
115
|
it('pageConfigPath matches the pageKey', () => {
|
|
112
|
-
const result = buildCatalogAddCommit({
|
|
116
|
+
const result = buildCatalogAddCommit({
|
|
117
|
+
...opts,
|
|
118
|
+
pageKey: 'about',
|
|
119
|
+
pageConfigPath: 'content/pages/_about.json',
|
|
120
|
+
})
|
|
113
121
|
expect(result.pageConfigPath).toBe('content/pages/_about.json')
|
|
114
122
|
})
|
|
115
123
|
})
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* @vitest-environment node
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { describe,
|
|
7
|
-
import {
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import { SETZKASTEN_CO_AUTHOR, withTrailers } from '../_commit-trailers'
|
|
8
8
|
|
|
9
9
|
describe('SETZKASTEN_CO_AUTHOR', () => {
|
|
10
10
|
it('is a valid Co-authored-by trailer', () => {
|
|
@@ -28,20 +28,20 @@ describe('withTrailers', () => {
|
|
|
28
28
|
|
|
29
29
|
it('does not add editor trailer when editorEmail is not provided', () => {
|
|
30
30
|
const result = withTrailers('chore: something')
|
|
31
|
-
const lines = result.split('\n').filter(l => l.startsWith('Co-authored-by:'))
|
|
31
|
+
const lines = result.split('\n').filter((l) => l.startsWith('Co-authored-by:'))
|
|
32
32
|
expect(lines).toHaveLength(1)
|
|
33
33
|
expect(lines[0]).toBe(SETZKASTEN_CO_AUTHOR)
|
|
34
34
|
})
|
|
35
35
|
|
|
36
36
|
it('does not add editor trailer when editorEmail is null', () => {
|
|
37
37
|
const result = withTrailers('chore: something', null)
|
|
38
|
-
const lines = result.split('\n').filter(l => l.startsWith('Co-authored-by:'))
|
|
38
|
+
const lines = result.split('\n').filter((l) => l.startsWith('Co-authored-by:'))
|
|
39
39
|
expect(lines).toHaveLength(1)
|
|
40
40
|
})
|
|
41
41
|
|
|
42
42
|
it('adds editor Co-authored-by when editorEmail is provided', () => {
|
|
43
43
|
const result = withTrailers('content: update', 'jane.doe@example.com')
|
|
44
|
-
const lines = result.split('\n').filter(l => l.startsWith('Co-authored-by:'))
|
|
44
|
+
const lines = result.split('\n').filter((l) => l.startsWith('Co-authored-by:'))
|
|
45
45
|
expect(lines).toHaveLength(2)
|
|
46
46
|
expect(lines[1]).toContain('jane.doe@example.com')
|
|
47
47
|
})
|