@setzkasten-cms/astro-admin 0.8.0 → 1.1.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.
Files changed (68) hide show
  1. package/package.json +16 -6
  2. package/src/admin-page.astro +1 -1
  3. package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
  4. package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
  5. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  6. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  7. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  8. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  9. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  10. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
  11. package/src/api-routes/__tests__/route-registry.test.ts +120 -0
  12. package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
  13. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +145 -0
  14. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
  15. package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
  16. package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
  17. package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
  18. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  19. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  20. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  21. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  22. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  23. package/src/api-routes/_auth-guard.ts +134 -13
  24. package/src/api-routes/_github-token.ts +64 -0
  25. package/src/api-routes/_license-tier.ts +25 -0
  26. package/src/api-routes/_pages-meta-store.ts +134 -0
  27. package/src/api-routes/_session-cookie.ts +42 -0
  28. package/src/api-routes/_storage-config.ts +64 -4
  29. package/src/api-routes/_vercel-origin.ts +22 -0
  30. package/src/api-routes/_website-resolver.ts +243 -0
  31. package/src/api-routes/_websites-store.ts +120 -0
  32. package/src/api-routes/asset-proxy.ts +6 -4
  33. package/src/api-routes/auth-callback.ts +6 -7
  34. package/src/api-routes/auth-logout.ts +5 -1
  35. package/src/api-routes/auth-setzkasten-login.ts +21 -10
  36. package/src/api-routes/catalog-add.ts +9 -5
  37. package/src/api-routes/catalog-export.ts +8 -4
  38. package/src/api-routes/config.ts +12 -5
  39. package/src/api-routes/editors.ts +79 -10
  40. package/src/api-routes/github-proxy.ts +5 -5
  41. package/src/api-routes/global-config.ts +23 -6
  42. package/src/api-routes/init-add-section.ts +13 -5
  43. package/src/api-routes/init-apply.ts +5 -3
  44. package/src/api-routes/init-migrate.ts +7 -5
  45. package/src/api-routes/init-scan-page.ts +26 -6
  46. package/src/api-routes/init-scan.ts +5 -3
  47. package/src/api-routes/migrate-to-multi.ts +255 -0
  48. package/src/api-routes/pages.ts +118 -4
  49. package/src/api-routes/section-add.ts +15 -5
  50. package/src/api-routes/section-commit-pending.ts +18 -5
  51. package/src/api-routes/section-delete.ts +15 -5
  52. package/src/api-routes/section-duplicate.ts +15 -5
  53. package/src/api-routes/section-prepare-copy.ts +15 -4
  54. package/src/api-routes/section-prepare.ts +9 -5
  55. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  56. package/src/api-routes/setup-github-app-branches.ts +63 -0
  57. package/src/api-routes/setup-github-app-callback.ts +53 -0
  58. package/src/api-routes/setup-github-app-installed.ts +44 -0
  59. package/src/api-routes/setup-github-app-repos.ts +46 -0
  60. package/src/api-routes/setup-github-app.ts +58 -0
  61. package/src/api-routes/updater-register.ts +6 -23
  62. package/src/api-routes/updater-transfer.ts +1 -12
  63. package/src/api-routes/websites-add.ts +113 -0
  64. package/src/api-routes/websites-list.ts +40 -0
  65. package/src/api-routes/websites-remove.ts +74 -0
  66. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  67. package/src/init/template-patcher-v2.ts +33 -0
  68. package/LICENSE +0 -37
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Tests for setup-github-app-callback.ts
3
+ *
4
+ * Key regression: cookies.set() nach Response.redirect() wirft
5
+ * "TypeError: immutable" weil Response.redirect() eine frozen Response
6
+ * zurückgibt. Fix: manuelles new Response(null, { status: 302 }).
7
+ *
8
+ * @vitest-environment node
9
+ */
10
+
11
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Minimal Astro context mock
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function makeCookies() {
18
+ const jar: Record<string, string> = {}
19
+ return {
20
+ set: vi.fn((name: string, value: string) => { jar[name] = value }),
21
+ get: vi.fn((name: string) => jar[name] ? { value: jar[name] } : undefined),
22
+ _jar: jar,
23
+ }
24
+ }
25
+
26
+ function makeCtx(code: string | null, headers: Record<string, string> = {}) {
27
+ const search = code ? `?code=${code}` : ''
28
+ const url = new URL(`https://localhost/api/setzkasten/setup/github-app/callback${search}`)
29
+ const request = new Request(url, { headers: { 'x-forwarded-host': 'setzkasten.vercel.app', 'x-forwarded-proto': 'https', ...headers } })
30
+ return { url, request, cookies: makeCookies() }
31
+ }
32
+
33
+ const GITHUB_RESPONSE = {
34
+ id: 123456,
35
+ slug: 'meine-cms-app',
36
+ pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----\n',
37
+ client_id: 'Iv1.abc',
38
+ client_secret: 'secret',
39
+ webhook_secret: null,
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Tests
44
+ // ---------------------------------------------------------------------------
45
+
46
+ describe('setup-github-app-callback', () => {
47
+ beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
48
+ afterEach(() => { vi.restoreAllMocks(); vi.resetModules() })
49
+
50
+ it('setzt Cookie und leitet zum Standard-adminPath weiter bei erfolgreichem Code-Austausch', async () => {
51
+ vi.mocked(fetch).mockResolvedValueOnce({
52
+ ok: true,
53
+ json: async () => GITHUB_RESPONSE,
54
+ } as Response)
55
+
56
+ const { GET } = await import('../setup-github-app-callback')
57
+ const ctx = makeCtx('abc123')
58
+ const res = await (GET as Function)(ctx)
59
+
60
+ expect(res.status).toBe(302)
61
+ expect(res.headers.get('location')).toBe('https://setzkasten.vercel.app/admin')
62
+ expect(ctx.cookies.set).toHaveBeenCalledOnce()
63
+
64
+ const stored = ctx.cookies._jar['sk_app_setup']
65
+ expect(stored).toBeDefined()
66
+ const cookieValue = JSON.parse(stored as string)
67
+ expect(cookieValue.appId).toBe('123456')
68
+ expect(cookieValue.slug).toBe('meine-cms-app')
69
+ expect(cookieValue.privateKey).toContain('RSA PRIVATE KEY')
70
+ })
71
+
72
+ it('Response ist nicht immutable — Cookie-Header kann gesetzt werden', async () => {
73
+ vi.mocked(fetch).mockResolvedValueOnce({
74
+ ok: true,
75
+ json: async () => GITHUB_RESPONSE,
76
+ } as Response)
77
+
78
+ const { GET } = await import('../setup-github-app-callback')
79
+ const ctx = makeCtx('abc123')
80
+ const res = await (GET as Function)(ctx)
81
+
82
+ // Wenn Response.redirect() verwendet würde, wäre der Response immutable
83
+ // und das Setzen eines Headers würde "TypeError: immutable" werfen.
84
+ // Dieser Test schlägt fehl, wenn die falsche Implementierung verwendet wird.
85
+ expect(() => res.headers.set('X-Test', 'ok')).not.toThrow()
86
+ })
87
+
88
+ it('leitet mit github-app-error=missing_code weiter wenn kein Code', async () => {
89
+ const { GET } = await import('../setup-github-app-callback')
90
+ const ctx = makeCtx(null)
91
+ const res = await (GET as Function)(ctx)
92
+
93
+ expect(res.status).toBe(302)
94
+ expect(res.headers.get('location')).toContain('github-app-error=missing_code')
95
+ expect(ctx.cookies.set).not.toHaveBeenCalled()
96
+ })
97
+
98
+ it('leitet mit github-app-error=exchange_failed weiter wenn GitHub-API fehlschlägt', async () => {
99
+ vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 422, json: async () => ({}) } as Response)
100
+
101
+ const { GET } = await import('../setup-github-app-callback')
102
+ const ctx = makeCtx('badcode')
103
+ const res = await (GET as Function)(ctx)
104
+
105
+ expect(res.status).toBe(302)
106
+ expect(res.headers.get('location')).toContain('github-app-error=exchange_failed')
107
+ expect(ctx.cookies.set).not.toHaveBeenCalled()
108
+ })
109
+
110
+ it('Redirect-URL enthält den echten Host aus x-forwarded-host', async () => {
111
+ vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => GITHUB_RESPONSE } as Response)
112
+
113
+ const { GET } = await import('../setup-github-app-callback')
114
+ const ctx = makeCtx('abc123', { 'x-forwarded-host': 'meine-website.de', 'x-forwarded-proto': 'https' })
115
+ const res = await (GET as Function)(ctx)
116
+
117
+ expect(res.headers.get('location')).toBe('https://meine-website.de/admin')
118
+ })
119
+ })
120
+
121
+ describe('setup-github-app-callback – adminPath', () => {
122
+ beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
123
+ afterEach(() => { vi.restoreAllMocks(); vi.resetModules() })
124
+
125
+ it('verwendet /admin als Standard-Redirect wenn kein adminPath konfiguriert', async () => {
126
+ vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => GITHUB_RESPONSE } as Response)
127
+ ;(globalThis as any).__SETZKASTEN_CONFIG__ = undefined
128
+
129
+ const { GET } = await import('../setup-github-app-callback')
130
+ const res = await (GET as Function)(makeCtx('abc123'))
131
+
132
+ const location = new URL(res.headers.get('location') ?? '')
133
+ expect(location.pathname).toBe('/admin')
134
+ })
135
+
136
+ it('verwendet konfigurierten adminPath für den Redirect', async () => {
137
+ vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => GITHUB_RESPONSE } as Response)
138
+ ;(globalThis as any).__SETZKASTEN_CONFIG__ = { adminPath: '/cms' }
139
+
140
+ const { GET } = await import('../setup-github-app-callback')
141
+ const res = await (GET as Function)(makeCtx('abc123'))
142
+
143
+ expect(res.headers.get('location')).toContain('/cms')
144
+ })
145
+ })
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Tests for setup-github-app-repos.ts and setup-github-app-branches.ts.
3
+ *
4
+ * Both routes are admin-only and read GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY
5
+ * from the env. We mock fetch to simulate GitHub responses.
6
+ *
7
+ * @vitest-environment node
8
+ */
9
+
10
+ import { generateKeyPairSync } from 'node:crypto'
11
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
12
+
13
+ const { privateKey: KEY } = generateKeyPairSync('rsa', { modulusLength: 2048 })
14
+ const PRIVATE_KEY_PEM = KEY.export({ type: 'pkcs8', format: 'pem' }) as string
15
+
16
+ // The setup endpoints are admin-gated, so the placeholder "tok" string the
17
+ // tests used to pass would now be rejected before reaching the GitHub layer.
18
+ // Replace it with a real serialized admin session and let callers opt out
19
+ // via session: null when they want to exercise the unauthenticated branch.
20
+ const ADMIN_SESSION = JSON.stringify({
21
+ user: {
22
+ id: 'u1',
23
+ email: 'admin@example.com',
24
+ role: 'admin',
25
+ provider: 'github',
26
+ },
27
+ expiresAt: Date.now() + 60 * 60 * 1000,
28
+ })
29
+
30
+ function makeCookies(session?: string) {
31
+ const jar: Record<string, string> = {}
32
+ // Treat the legacy "tok" placeholder as an admin session; explicit
33
+ // undefined keeps the cookie absent (401 path).
34
+ const resolved = session === 'tok' ? ADMIN_SESSION : session
35
+ if (resolved) jar.setzkasten_session = resolved
36
+ return {
37
+ get: vi.fn((name: string) => (jar[name] ? { value: jar[name] } : undefined)),
38
+ set: vi.fn(),
39
+ }
40
+ }
41
+
42
+ function makeCtx(opts: { session?: string; query?: string } = {}) {
43
+ const url = new URL(
44
+ `https://localhost/api/setzkasten/setup/github-app/repos${opts.query ?? ''}`,
45
+ )
46
+ return {
47
+ url,
48
+ cookies: makeCookies(opts.session),
49
+ request: new Request(url),
50
+ }
51
+ }
52
+
53
+ interface MockResponse {
54
+ status?: number
55
+ body: unknown
56
+ }
57
+
58
+ function mockFetchSequence(steps: Array<(url: string) => MockResponse>) {
59
+ let i = 0
60
+ vi.stubGlobal(
61
+ 'fetch',
62
+ vi.fn(async (input: string | URL) => {
63
+ const url = typeof input === 'string' ? input : input.toString()
64
+ const step = steps[i] ?? steps.at(-1)!
65
+ i += 1
66
+ const response = step(url)
67
+ const status = response.status ?? 200
68
+ return {
69
+ ok: status >= 200 && status < 300,
70
+ status,
71
+ json: async () => response.body,
72
+ } as Response
73
+ }),
74
+ )
75
+ }
76
+
77
+ describe('setup-github-app-repos', () => {
78
+ beforeEach(() => {
79
+ process.env.GITHUB_APP_ID = '12345'
80
+ process.env.GITHUB_APP_PRIVATE_KEY = PRIVATE_KEY_PEM
81
+ })
82
+ afterEach(() => {
83
+ vi.restoreAllMocks()
84
+ vi.resetModules()
85
+ delete process.env.GITHUB_APP_ID
86
+ delete process.env.GITHUB_APP_PRIVATE_KEY
87
+ })
88
+
89
+ it('rejects unauthenticated requests with 401', async () => {
90
+ const { GET } = await import('../setup-github-app-repos')
91
+ // biome-ignore lint/suspicious/noExplicitAny: minimal Astro context shape
92
+ const res = await GET(makeCtx() as any)
93
+ expect(res.status).toBe(401)
94
+ })
95
+
96
+ it('returns 400 when the GitHub App env vars are missing', async () => {
97
+ delete process.env.GITHUB_APP_ID
98
+ const { GET } = await import('../setup-github-app-repos')
99
+ // biome-ignore lint/suspicious/noExplicitAny: minimal Astro context shape
100
+ const res = await GET(makeCtx({ session: 'tok' }) as any)
101
+ expect(res.status).toBe(400)
102
+ })
103
+
104
+ it('returns the flattened repo list across installations', async () => {
105
+ mockFetchSequence([
106
+ // List installations
107
+ () => ({
108
+ body: [{ id: 100, account: { login: 'acme' } }],
109
+ }),
110
+ // Installation token
111
+ () => ({
112
+ body: { token: 'tok', expires_at: new Date(Date.now() + 3600_000).toISOString() },
113
+ }),
114
+ // Repos
115
+ () => ({
116
+ body: {
117
+ repositories: [
118
+ {
119
+ full_name: 'acme/site',
120
+ name: 'site',
121
+ owner: { login: 'acme' },
122
+ default_branch: 'main',
123
+ private: false,
124
+ },
125
+ ],
126
+ },
127
+ }),
128
+ ])
129
+
130
+ const { GET } = await import('../setup-github-app-repos')
131
+ // biome-ignore lint/suspicious/noExplicitAny: minimal Astro context shape
132
+ const res = await GET(makeCtx({ session: 'tok' }) as any)
133
+ expect(res.status).toBe(200)
134
+ const body = (await res.json()) as { repos: Array<Record<string, unknown>> }
135
+ expect(body.repos).toHaveLength(1)
136
+ expect(body.repos[0]).toMatchObject({
137
+ installationId: '100',
138
+ fullName: 'acme/site',
139
+ defaultBranch: 'main',
140
+ })
141
+ })
142
+ })
143
+
144
+ describe('setup-github-app-branches', () => {
145
+ beforeEach(() => {
146
+ process.env.GITHUB_APP_ID = '12345'
147
+ process.env.GITHUB_APP_PRIVATE_KEY = PRIVATE_KEY_PEM
148
+ })
149
+ afterEach(() => {
150
+ vi.restoreAllMocks()
151
+ vi.resetModules()
152
+ delete process.env.GITHUB_APP_ID
153
+ delete process.env.GITHUB_APP_PRIVATE_KEY
154
+ })
155
+
156
+ it('returns 400 when ?repo or ?installation is missing', async () => {
157
+ const { GET } = await import('../setup-github-app-branches')
158
+ const url = new URL('https://localhost/api/setzkasten/setup/github-app/branches')
159
+ const ctx = {
160
+ url,
161
+ cookies: makeCookies('tok'),
162
+ request: new Request(url),
163
+ }
164
+ // biome-ignore lint/suspicious/noExplicitAny: minimal Astro context shape
165
+ const res = await GET(ctx as any)
166
+ expect(res.status).toBe(400)
167
+ })
168
+
169
+ it('returns the branch list for a repo', async () => {
170
+ mockFetchSequence([
171
+ () => ({
172
+ body: { token: 'tok', expires_at: new Date(Date.now() + 3600_000).toISOString() },
173
+ }),
174
+ () => ({ body: [{ name: 'main' }, { name: 'develop' }] }),
175
+ ])
176
+
177
+ const { GET } = await import('../setup-github-app-branches')
178
+ const url = new URL(
179
+ 'https://localhost/api/setzkasten/setup/github-app/branches?installation=100&repo=acme/site',
180
+ )
181
+ const ctx = {
182
+ url,
183
+ cookies: makeCookies('tok'),
184
+ request: new Request(url),
185
+ }
186
+ // biome-ignore lint/suspicious/noExplicitAny: minimal Astro context shape
187
+ const res = await GET(ctx as any)
188
+ expect(res.status).toBe(200)
189
+ const body = (await res.json()) as { branches: string[] }
190
+ expect(body.branches).toEqual(['develop', 'main'])
191
+ })
192
+ })
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Tests for setup-github-app.ts API route.
3
+ *
4
+ * Covers:
5
+ * GET 1. Nicht konfiguriert → { configured: false }
6
+ * GET 2. Konfiguriert (GITHUB_APP_* Vars gesetzt) → { configured: true, appId }
7
+ * POST 3. Gültige Credentials → { ok: true }
8
+ * POST 4. Fehlende installationId → 400
9
+ * POST 5. Ungültige Credentials (Token-Fetch schlägt fehl) → { ok: false, error }
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
13
+ import { generateKeyPairSync } from 'node:crypto'
14
+
15
+ const { privateKey: TEST_KEY } = generateKeyPairSync('rsa', { modulusLength: 2048 })
16
+ const TEST_PEM = TEST_KEY.export({ type: 'pkcs8', format: 'pem' }) as string
17
+
18
+ type MockRequest = { json: () => Promise<unknown> }
19
+ type MockContext = { request: MockRequest }
20
+
21
+ function makeCtx(body: unknown): MockContext {
22
+ return { request: { json: async () => body } }
23
+ }
24
+
25
+ function setEnv(vars: Record<string, string | undefined>) {
26
+ for (const [k, v] of Object.entries(vars))
27
+ v === undefined ? delete process.env[k] : (process.env[k] = v)
28
+ }
29
+
30
+ describe('setup-github-app GET', () => {
31
+ afterEach(() => {
32
+ setEnv({ GITHUB_APP_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined, GITHUB_APP_INSTALLATION_ID: undefined })
33
+ vi.resetModules()
34
+ })
35
+
36
+ it('gibt configured: false zurück wenn App nicht konfiguriert', async () => {
37
+ const { GET } = await import('../setup-github-app')
38
+ const res = await (GET as Function)({})
39
+ const body = await res.json()
40
+ expect(res.status).toBe(200)
41
+ expect(body.configured).toBe(false)
42
+ })
43
+
44
+ it('gibt configured: true + appId zurück wenn alle Vars gesetzt sind', async () => {
45
+ setEnv({ GITHUB_APP_ID: '99999', GITHUB_APP_PRIVATE_KEY: TEST_PEM, GITHUB_APP_INSTALLATION_ID: '11111' })
46
+ vi.resetModules()
47
+ const { GET } = await import('../setup-github-app')
48
+ const res = await (GET as Function)({})
49
+ const body = await res.json()
50
+ expect(res.status).toBe(200)
51
+ expect(body.configured).toBe(true)
52
+ expect(body.appId).toBe('99999')
53
+ })
54
+ })
55
+
56
+ describe('setup-github-app POST', () => {
57
+ beforeEach(() => {
58
+ vi.stubGlobal('fetch', vi.fn())
59
+ vi.resetModules()
60
+ })
61
+
62
+ afterEach(() => {
63
+ vi.restoreAllMocks()
64
+ vi.resetModules()
65
+ })
66
+
67
+ it('gibt ok: true zurück wenn Verbindungstest erfolgreich', async () => {
68
+ vi.mocked(fetch).mockResolvedValueOnce({
69
+ ok: true,
70
+ json: async () => ({
71
+ token: 'ghs_test',
72
+ expires_at: new Date(Date.now() + 3600_000).toISOString(),
73
+ }),
74
+ } as Response)
75
+
76
+ const { POST } = await import('../setup-github-app')
77
+ const ctx = makeCtx({ appId: '123', privateKey: TEST_PEM, installationId: '456' })
78
+ const res = await (POST as Function)(ctx)
79
+ const body = await res.json()
80
+
81
+ expect(res.status).toBe(200)
82
+ expect(body.ok).toBe(true)
83
+ })
84
+
85
+ it('gibt 400 zurück wenn installationId fehlt', async () => {
86
+ const { POST } = await import('../setup-github-app')
87
+ const ctx = makeCtx({ appId: '123', privateKey: TEST_PEM })
88
+ const res = await (POST as Function)(ctx)
89
+ expect(res.status).toBe(400)
90
+ })
91
+
92
+ it('gibt ok: false zurück wenn GitHub-API-Aufruf fehlschlägt', async () => {
93
+ vi.mocked(fetch).mockResolvedValueOnce({
94
+ ok: false,
95
+ json: async () => ({ message: 'Bad credentials' }),
96
+ } as Response)
97
+
98
+ const { POST } = await import('../setup-github-app')
99
+ const ctx = makeCtx({ appId: '123', privateKey: TEST_PEM, installationId: '456' })
100
+ const res = await (POST as Function)(ctx)
101
+ const body = await res.json()
102
+
103
+ expect(res.status).toBe(400)
104
+ expect(body.ok).toBe(false)
105
+ expect(body.error).toBeTruthy()
106
+ })
107
+ })
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { type Result, type WebsiteEntry, ok } from '@setzkasten-cms/core'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+ import { resolveStorageConfigForRequest } from '../_storage-config'
8
+ import { __resetWebsiteResolverForTests } from '../_website-resolver'
9
+
10
+ const ENTRY: WebsiteEntry = {
11
+ id: 'site-a',
12
+ name: 'Site A',
13
+ repo: 'acme/site-a',
14
+ branch: 'develop',
15
+ previewOrigin: 'https://a.example.com',
16
+ githubApp: { appId: '11', installationId: '101' },
17
+ }
18
+
19
+ function makeRegistry(entries: readonly WebsiteEntry[]) {
20
+ return {
21
+ list: vi.fn(async (): Promise<Result<readonly WebsiteEntry[]>> => ok(entries)),
22
+ get: vi.fn(async (id: string): Promise<Result<WebsiteEntry | null>> => {
23
+ return ok(entries.find((e) => e.id === id) ?? null)
24
+ }),
25
+ }
26
+ }
27
+
28
+ beforeEach(() => {
29
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
30
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = undefined
31
+ })
32
+
33
+ afterEach(() => __resetWebsiteResolverForTests(null))
34
+
35
+ describe('resolveStorageConfigForRequest', () => {
36
+ it('derives owner/repo/branch from the resolved website entry', async () => {
37
+ __resetWebsiteResolverForTests({ mode: 'multi', registry: makeRegistry([ENTRY]) })
38
+ const req = new Request('https://cms.example.com/x', {
39
+ headers: { 'x-sk-website': 'site-a' },
40
+ })
41
+
42
+ const result = await resolveStorageConfigForRequest(req)
43
+
44
+ expect(result).not.toBeNull()
45
+ expect(result?.owner).toBe('acme')
46
+ expect(result?.repo).toBe('site-a')
47
+ expect(result?.branch).toBe('develop')
48
+ expect(result?.projectPrefix).toBe('')
49
+ })
50
+
51
+ it('returns null when no website matches and no body override is given', async () => {
52
+ __resetWebsiteResolverForTests({ mode: 'multi', registry: makeRegistry([ENTRY]) })
53
+ const req = new Request('https://cms.example.com/x', {
54
+ headers: { 'x-sk-website': 'unknown' },
55
+ })
56
+
57
+ const result = await resolveStorageConfigForRequest(req)
58
+
59
+ expect(result).toBeNull()
60
+ })
61
+
62
+ it('honours an explicit body override even when resolver yields a website', async () => {
63
+ __resetWebsiteResolverForTests({ mode: 'multi', registry: makeRegistry([ENTRY]) })
64
+ const req = new Request('https://cms.example.com/x', {
65
+ headers: { 'x-sk-website': 'site-a' },
66
+ })
67
+
68
+ const result = await resolveStorageConfigForRequest(req, {
69
+ owner: 'override',
70
+ repo: 'forced',
71
+ branch: 'main',
72
+ })
73
+
74
+ expect(result?.owner).toBe('override')
75
+ expect(result?.repo).toBe('forced')
76
+ expect(result?.branch).toBe('main')
77
+ })
78
+ })
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { generateKeyPairSync } from 'node:crypto'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+ import {
8
+ __resetWebsiteResolverForTests,
9
+ bootstrapResolverFromGlobals,
10
+ listAllWebsites,
11
+ } from '../_website-resolver'
12
+
13
+ const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
14
+ const PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
15
+
16
+ const REGISTRY = {
17
+ websites: [
18
+ {
19
+ id: 'a',
20
+ name: 'A',
21
+ repo: 'acme/a',
22
+ branch: 'main',
23
+ previewOrigin: 'https://a.example.com',
24
+ githubApp: { appId: '1', installationId: '101' },
25
+ },
26
+ ],
27
+ }
28
+
29
+ beforeEach(() => {
30
+ __resetWebsiteResolverForTests(null)
31
+ vi.unstubAllEnvs()
32
+ vi.stubEnv('GITHUB_APP_ID', '1')
33
+ vi.stubEnv('GITHUB_APP_INSTALLATION_ID', '111')
34
+ vi.stubEnv('GITHUB_APP_PRIVATE_KEY', PEM)
35
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
36
+ storage: {
37
+ kind: 'standalone',
38
+ configRepo: 'acme/cms-config',
39
+ configBranch: 'main',
40
+ appId: '1',
41
+ installationId: '111',
42
+ },
43
+ }
44
+ })
45
+
46
+ afterEach(() => {
47
+ __resetWebsiteResolverForTests(null)
48
+ vi.unstubAllEnvs()
49
+ vi.restoreAllMocks()
50
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
51
+ })
52
+
53
+ describe('bootstrapResolverFromGlobals – standalone activation', () => {
54
+ it('returns the registry list from the config repo', async () => {
55
+ const fetchMock = vi.fn(async (url: string) => {
56
+ if (url.includes('/access_tokens')) {
57
+ return new Response(
58
+ JSON.stringify({
59
+ token: 'gh_mock',
60
+ expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
61
+ }),
62
+ { status: 200, headers: { 'content-type': 'application/json' } },
63
+ )
64
+ }
65
+ if (url.includes('/contents/websites.json')) {
66
+ return new Response(
67
+ JSON.stringify({
68
+ type: 'file',
69
+ content: Buffer.from(JSON.stringify(REGISTRY)).toString('base64'),
70
+ sha: 'abc',
71
+ }),
72
+ { status: 200, headers: { 'content-type': 'application/json' } },
73
+ )
74
+ }
75
+ throw new Error(`unexpected URL: ${url}`)
76
+ })
77
+ vi.stubGlobal('fetch', fetchMock)
78
+
79
+ bootstrapResolverFromGlobals()
80
+
81
+ const result = await listAllWebsites()
82
+ expect(result.ok).toBe(true)
83
+ if (result.ok) expect(result.value.map((w) => w.id)).toEqual(['a'])
84
+ })
85
+ })