@setzkasten-cms/astro-admin 0.8.0 → 1.3.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 (85) hide show
  1. package/package.json +22 -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__/feature-gate.test.ts +60 -0
  5. package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
  6. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  7. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  8. package/src/api-routes/__tests__/history-rollback.test.ts +196 -0
  9. package/src/api-routes/__tests__/history.test.ts +168 -0
  10. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  11. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  12. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  13. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
  14. package/src/api-routes/__tests__/route-registry.test.ts +120 -0
  15. package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
  16. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +152 -0
  17. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
  18. package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
  19. package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
  20. package/src/api-routes/__tests__/webhook-signing.test.ts +39 -0
  21. package/src/api-routes/__tests__/webhooks.test.ts +219 -0
  22. package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
  23. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  24. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  25. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  26. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  27. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  28. package/src/api-routes/_auth-guard.ts +134 -13
  29. package/src/api-routes/_feature-gate.ts +39 -0
  30. package/src/api-routes/_github-token.ts +64 -0
  31. package/src/api-routes/_license-tier.ts +25 -0
  32. package/src/api-routes/_pages-meta-store.ts +134 -0
  33. package/src/api-routes/_role-resolver.ts +60 -0
  34. package/src/api-routes/_session-cookie.ts +42 -0
  35. package/src/api-routes/_storage-config.ts +77 -4
  36. package/src/api-routes/_vercel-origin.ts +22 -0
  37. package/src/api-routes/_webhook-dispatcher.ts +120 -0
  38. package/src/api-routes/_webhook-signing.ts +13 -0
  39. package/src/api-routes/_webhook-status-store.ts +31 -0
  40. package/src/api-routes/_website-resolver.ts +243 -0
  41. package/src/api-routes/_websites-store.ts +120 -0
  42. package/src/api-routes/asset-proxy.ts +6 -4
  43. package/src/api-routes/auth-callback.ts +8 -7
  44. package/src/api-routes/auth-logout.ts +5 -1
  45. package/src/api-routes/auth-setzkasten-login.ts +37 -11
  46. package/src/api-routes/catalog-add.ts +9 -5
  47. package/src/api-routes/catalog-export.ts +8 -4
  48. package/src/api-routes/config.ts +12 -5
  49. package/src/api-routes/editors.ts +94 -10
  50. package/src/api-routes/github-proxy.ts +5 -5
  51. package/src/api-routes/global-config.ts +23 -6
  52. package/src/api-routes/history-rollback.ts +144 -0
  53. package/src/api-routes/history-version.ts +57 -0
  54. package/src/api-routes/history.ts +119 -0
  55. package/src/api-routes/init-add-section.ts +13 -5
  56. package/src/api-routes/init-apply.ts +5 -3
  57. package/src/api-routes/init-migrate.ts +7 -5
  58. package/src/api-routes/init-scan-page.ts +26 -6
  59. package/src/api-routes/init-scan.ts +5 -3
  60. package/src/api-routes/migrate-to-multi.ts +255 -0
  61. package/src/api-routes/pages.ts +118 -4
  62. package/src/api-routes/section-add.ts +15 -5
  63. package/src/api-routes/section-commit-pending.ts +117 -5
  64. package/src/api-routes/section-delete.ts +29 -5
  65. package/src/api-routes/section-duplicate.ts +15 -5
  66. package/src/api-routes/section-prepare-copy.ts +15 -4
  67. package/src/api-routes/section-prepare.ts +9 -5
  68. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  69. package/src/api-routes/setup-github-app-branches.ts +63 -0
  70. package/src/api-routes/setup-github-app-callback.ts +71 -0
  71. package/src/api-routes/setup-github-app-installed.ts +44 -0
  72. package/src/api-routes/setup-github-app-repos.ts +46 -0
  73. package/src/api-routes/setup-github-app.ts +58 -0
  74. package/src/api-routes/updater-register.ts +37 -25
  75. package/src/api-routes/updater-transfer.ts +1 -12
  76. package/src/api-routes/webhooks-status.ts +17 -0
  77. package/src/api-routes/webhooks-test.ts +134 -0
  78. package/src/api-routes/webhooks.ts +163 -0
  79. package/src/api-routes/websites-add.ts +113 -0
  80. package/src/api-routes/websites-list.ts +40 -0
  81. package/src/api-routes/websites-remove.ts +74 -0
  82. package/src/init/__tests__/patcher-edge-cases.test.ts +34 -1
  83. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  84. package/src/init/template-patcher-v2.ts +42 -4
  85. package/LICENSE +0 -37
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+
7
+ const TARGET = {
8
+ owner: 'acme',
9
+ repo: 'site',
10
+ branch: 'main',
11
+ contentPath: 'content',
12
+ token: 'gh-token',
13
+ }
14
+
15
+ beforeEach(() => {
16
+ vi.unstubAllEnvs()
17
+ })
18
+
19
+ afterEach(() => {
20
+ vi.restoreAllMocks()
21
+ vi.unstubAllEnvs()
22
+ })
23
+
24
+ function fetchSequence(steps: Array<(url: string, init?: RequestInit) => Response | Promise<Response>>) {
25
+ let i = 0
26
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
27
+ const handler = steps[Math.min(i++, steps.length - 1)]
28
+ if (!handler) throw new Error('fetchSequence: empty step list')
29
+ return handler(url, init)
30
+ })
31
+ vi.stubGlobal('fetch', fetchMock)
32
+ return fetchMock
33
+ }
34
+
35
+ describe('readPagesMeta', () => {
36
+ it('returns an empty meta when GitHub responds 404', async () => {
37
+ fetchSequence([() => new Response(null, { status: 404 })])
38
+
39
+ const { readPagesMeta } = await import('../_pages-meta-store')
40
+ const result = await readPagesMeta(TARGET)
41
+
42
+ expect(result.ok).toBe(true)
43
+ if (result.ok) {
44
+ expect(result.value.meta.pages).toEqual({})
45
+ expect(result.value.sha).toBeNull()
46
+ }
47
+ })
48
+
49
+ it('returns the parsed meta + sha when the file exists', async () => {
50
+ const meta = { version: 1, pages: { index: { lastModified: 5 } } }
51
+ fetchSequence([
52
+ () =>
53
+ new Response(
54
+ JSON.stringify({ content: Buffer.from(JSON.stringify(meta)).toString('base64'), sha: 'abc' }),
55
+ { status: 200, headers: { 'content-type': 'application/json' } },
56
+ ),
57
+ ])
58
+
59
+ const { readPagesMeta } = await import('../_pages-meta-store')
60
+ const result = await readPagesMeta(TARGET)
61
+
62
+ expect(result.ok).toBe(true)
63
+ if (result.ok) {
64
+ expect(result.value.meta.pages.index?.lastModified).toBe(5)
65
+ expect(result.value.sha).toBe('abc')
66
+ }
67
+ })
68
+
69
+ it('falls through to network error on non-404 failures', async () => {
70
+ fetchSequence([() => new Response('boom', { status: 500 })])
71
+
72
+ const { readPagesMeta } = await import('../_pages-meta-store')
73
+ const result = await readPagesMeta(TARGET)
74
+
75
+ expect(result.ok).toBe(false)
76
+ })
77
+ })
78
+
79
+ describe('recordPageEdit', () => {
80
+ it('reads, sets the timestamp, writes back — with the correct sha', async () => {
81
+ const calls: { url: string; method?: string; body?: unknown }[] = []
82
+ const existingMeta = { version: 1, pages: { index: { lastModified: 1 } } }
83
+ fetchSequence([
84
+ // 1) GET existing meta
85
+ (url, init) => {
86
+ calls.push({ url, method: init?.method, body: init?.body })
87
+ return new Response(
88
+ JSON.stringify({
89
+ content: Buffer.from(JSON.stringify(existingMeta)).toString('base64'),
90
+ sha: 'old-sha',
91
+ }),
92
+ { status: 200, headers: { 'content-type': 'application/json' } },
93
+ )
94
+ },
95
+ // 2) PUT updated meta
96
+ (url, init) => {
97
+ calls.push({ url, method: init?.method, body: init?.body })
98
+ return new Response(JSON.stringify({ content: { sha: 'new-sha' } }), {
99
+ status: 200,
100
+ headers: { 'content-type': 'application/json' },
101
+ })
102
+ },
103
+ ])
104
+
105
+ const { recordPageEdit } = await import('../_pages-meta-store')
106
+ const result = await recordPageEdit(TARGET, 'about', 99)
107
+
108
+ expect(result.ok).toBe(true)
109
+ expect(calls).toHaveLength(2)
110
+ expect(calls[1]?.method).toBe('PUT')
111
+ const writtenBody = JSON.parse(String(calls[1]?.body)) as { content: string; sha?: string }
112
+ expect(writtenBody.sha).toBe('old-sha')
113
+ const writtenMeta = JSON.parse(Buffer.from(writtenBody.content, 'base64').toString('utf-8'))
114
+ expect(writtenMeta.pages.index.lastModified).toBe(1)
115
+ expect(writtenMeta.pages.about.lastModified).toBe(99)
116
+ })
117
+
118
+ it('initialises the file when it does not exist (no sha sent)', async () => {
119
+ const calls: { url: string; method?: string; body?: unknown }[] = []
120
+ fetchSequence([
121
+ (url, init) => {
122
+ calls.push({ url, method: init?.method, body: init?.body })
123
+ return new Response(null, { status: 404 })
124
+ },
125
+ (url, init) => {
126
+ calls.push({ url, method: init?.method, body: init?.body })
127
+ return new Response(JSON.stringify({ content: { sha: 'first' } }), {
128
+ status: 200,
129
+ headers: { 'content-type': 'application/json' },
130
+ })
131
+ },
132
+ ])
133
+
134
+ const { recordPageEdit } = await import('../_pages-meta-store')
135
+ const result = await recordPageEdit(TARGET, 'about', 42)
136
+
137
+ expect(result.ok).toBe(true)
138
+ const putBody = JSON.parse(String(calls[1]?.body)) as { sha?: string }
139
+ expect(putBody.sha).toBeUndefined()
140
+ })
141
+
142
+ it('retries once on a 409 conflict, succeeds on the second write', async () => {
143
+ const empty = { version: 1, pages: {} }
144
+ fetchSequence([
145
+ () =>
146
+ new Response(
147
+ JSON.stringify({
148
+ content: Buffer.from(JSON.stringify(empty)).toString('base64'),
149
+ sha: 'first',
150
+ }),
151
+ { status: 200, headers: { 'content-type': 'application/json' } },
152
+ ),
153
+ // First PUT → 409
154
+ () => new Response('conflict', { status: 409 }),
155
+ // Re-read with newer sha
156
+ () =>
157
+ new Response(
158
+ JSON.stringify({
159
+ content: Buffer.from(JSON.stringify({ version: 1, pages: { x: { lastModified: 2 } } })).toString(
160
+ 'base64',
161
+ ),
162
+ sha: 'second',
163
+ }),
164
+ { status: 200, headers: { 'content-type': 'application/json' } },
165
+ ),
166
+ // Second PUT → ok
167
+ () =>
168
+ new Response(JSON.stringify({ content: { sha: 'third' } }), {
169
+ status: 200,
170
+ headers: { 'content-type': 'application/json' },
171
+ }),
172
+ ])
173
+
174
+ const { recordPageEdit } = await import('../_pages-meta-store')
175
+ const result = await recordPageEdit(TARGET, 'about', 7)
176
+
177
+ expect(result.ok).toBe(true)
178
+ })
179
+ })
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Route-Registry-Konsistenztest
3
+ *
4
+ * Jede öffentliche Route-Datei in packages/astro-admin/src/api-routes/
5
+ * muss an drei Stellen registriert sein:
6
+ * 1. Als Export in packages/astro-admin/package.json
7
+ * 2. Als injectRoute-Entrypoint in packages/astro/src/integration.ts
8
+ *
9
+ * Ausnahmen (kein Export, kein injectRoute nötig):
10
+ * - Dateien mit `_`-Prefix → interne Helpers
11
+ * - Dateien mit `-helpers` → interne Helpers
12
+ * - Dateien mit `-management` → interne Helpers (nur von anderen Routes importiert)
13
+ *
14
+ * Dieser Test verhindert, dass neue Route-Dateien vergessen werden —
15
+ * ein Fehler, der erst beim Produktions-Build auffällt (ENOENT).
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest'
19
+ import { readdirSync, readFileSync } from 'node:fs'
20
+ import { resolve, dirname } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url))
24
+ // Test sits at src/api-routes/__tests__, so go up three levels
25
+ const ADMIN_ROOT = resolve(__dirname, '../../../') // packages/astro-admin
26
+ const ASTRO_ROOT = resolve(__dirname, '../../../../astro') // packages/astro
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Load sources
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const routeFiles = readdirSync(resolve(ADMIN_ROOT, 'src/api-routes')).filter(
33
+ f => f.endsWith('.ts') && !f.endsWith('.test.ts'),
34
+ )
35
+
36
+ const packageJson = JSON.parse(
37
+ readFileSync(resolve(ADMIN_ROOT, 'package.json'), 'utf-8'),
38
+ ) as { exports: Record<string, string> }
39
+
40
+ const integrationSrc = readFileSync(
41
+ resolve(ASTRO_ROOT, 'src/integration.ts'),
42
+ 'utf-8',
43
+ )
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Helpers
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /** Files that are internal — no export or injectRoute needed */
50
+ function isInternal(filename: string): boolean {
51
+ const base = filename.replace(/\.ts$/, '')
52
+ return (
53
+ base.startsWith('_') ||
54
+ base.endsWith('-helpers') ||
55
+ base.endsWith('-management')
56
+ )
57
+ }
58
+
59
+ /** Export key as used in package.json, e.g. "catalog-list" → "./catalog" special-cased */
60
+ function exportKey(filename: string): string {
61
+ const base = filename.replace(/\.ts$/, '')
62
+ // Special case: catalog-list is exported as "./catalog"
63
+ if (base === 'catalog-list') return './catalog'
64
+ return `./${base}`
65
+ }
66
+
67
+ function entrypoint(filename: string): string {
68
+ const base = filename.replace(/\.ts$/, '')
69
+ if (base === 'catalog-list') return '@setzkasten-cms/astro-admin/catalog'
70
+ return `@setzkasten-cms/astro-admin/${base}`
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Tests
75
+ // ---------------------------------------------------------------------------
76
+
77
+ const publicRoutes = routeFiles.filter(f => !isInternal(f))
78
+
79
+ describe('Route-Registry-Konsistenz', () => {
80
+ describe('package.json exports', () => {
81
+ for (const file of publicRoutes) {
82
+ it(`${file} ist in package.json exports registriert`, () => {
83
+ const key = exportKey(file)
84
+ expect(
85
+ packageJson.exports,
86
+ `Fehlender Export: "${key}" in packages/astro-admin/package.json`,
87
+ ).toHaveProperty(key)
88
+ })
89
+ }
90
+ })
91
+
92
+ describe('integration.ts injectRoute', () => {
93
+ for (const file of publicRoutes) {
94
+ it(`${file} ist als injectRoute-Entrypoint in integration.ts registriert`, () => {
95
+ const ep = entrypoint(file)
96
+ expect(
97
+ integrationSrc,
98
+ `Fehlender injectRoute-Eintrag für '${ep}' in packages/astro/src/integration.ts`,
99
+ ).toContain(`'${ep}'`)
100
+ })
101
+ }
102
+ })
103
+
104
+ describe('Keine verwaisten Exports', () => {
105
+ it('alle package.json-Exports (astro-admin routes) haben eine entsprechende Datei', () => {
106
+ const routeExports = Object.entries(packageJson.exports)
107
+ .filter(([, v]) => v.startsWith('./src/api-routes/'))
108
+ .map(([k]) => k)
109
+
110
+ const knownKeys = new Set(publicRoutes.map(exportKey))
111
+
112
+ for (const key of routeExports) {
113
+ expect(
114
+ knownKeys.has(key),
115
+ `Export "${key}" in package.json hat keine Route-Datei mehr`,
116
+ ).toBe(true)
117
+ }
118
+ })
119
+ })
120
+ })
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+ import { sessionCookieOptions } from '../_session-cookie'
7
+
8
+ beforeEach(() => {
9
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
10
+ vi.unstubAllEnvs()
11
+ })
12
+
13
+ afterEach(() => {
14
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
15
+ vi.unstubAllEnvs()
16
+ })
17
+
18
+ describe('sessionCookieOptions', () => {
19
+ it('returns secure defaults regardless of config', () => {
20
+ const opts = sessionCookieOptions(false)
21
+
22
+ expect(opts.httpOnly).toBe(true)
23
+ expect(opts.sameSite).toBe('lax')
24
+ expect(opts.path).toBe('/')
25
+ expect(opts.maxAge).toBe(60 * 60 * 24 * 7)
26
+ })
27
+
28
+ it('toggles secure with the prod flag', () => {
29
+ expect(sessionCookieOptions(false).secure).toBe(false)
30
+ expect(sessionCookieOptions(true).secure).toBe(true)
31
+ })
32
+
33
+ it('omits domain when no cookieDomain is configured (single-repo default)', () => {
34
+ expect(sessionCookieOptions(true).domain).toBeUndefined()
35
+ })
36
+
37
+ it('reads cookieDomain from __SETZKASTEN_FULL_CONFIG__.auth.cookieDomain', () => {
38
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
39
+ auth: { cookieDomain: '.example.com' },
40
+ }
41
+
42
+ expect(sessionCookieOptions(true).domain).toBe('.example.com')
43
+ })
44
+
45
+ it('falls back to SETZKASTEN_COOKIE_DOMAIN env when full config has no value', () => {
46
+ vi.stubEnv('SETZKASTEN_COOKIE_DOMAIN', '.example.com')
47
+
48
+ expect(sessionCookieOptions(true).domain).toBe('.example.com')
49
+ })
50
+
51
+ it('config wins over env', () => {
52
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
53
+ auth: { cookieDomain: '.config.example.com' },
54
+ }
55
+ vi.stubEnv('SETZKASTEN_COOKIE_DOMAIN', '.env.example.com')
56
+
57
+ expect(sessionCookieOptions(true).domain).toBe('.config.example.com')
58
+ })
59
+
60
+ it('treats empty cookieDomain as unset', () => {
61
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
62
+ auth: { cookieDomain: '' },
63
+ }
64
+
65
+ expect(sessionCookieOptions(true).domain).toBeUndefined()
66
+ })
67
+ })
@@ -0,0 +1,152 @@
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
+ // v1.2: GitHub-App also serves as the OAuth provider for admin
71
+ // login. The Manifest exchange returns client_id + client_secret
72
+ // alongside the App credentials; we capture both so the wizard
73
+ // can surface them as env vars and the CLI doesn't need to ask
74
+ // for OAuth credentials separately.
75
+ expect(cookieValue.clientId).toBe('Iv1.abc')
76
+ expect(cookieValue.clientSecret).toBe('secret')
77
+ })
78
+
79
+ it('Response ist nicht immutable — Cookie-Header kann gesetzt werden', async () => {
80
+ vi.mocked(fetch).mockResolvedValueOnce({
81
+ ok: true,
82
+ json: async () => GITHUB_RESPONSE,
83
+ } as Response)
84
+
85
+ const { GET } = await import('../setup-github-app-callback')
86
+ const ctx = makeCtx('abc123')
87
+ const res = await (GET as Function)(ctx)
88
+
89
+ // Wenn Response.redirect() verwendet würde, wäre der Response immutable
90
+ // und das Setzen eines Headers würde "TypeError: immutable" werfen.
91
+ // Dieser Test schlägt fehl, wenn die falsche Implementierung verwendet wird.
92
+ expect(() => res.headers.set('X-Test', 'ok')).not.toThrow()
93
+ })
94
+
95
+ it('leitet mit github-app-error=missing_code weiter wenn kein Code', async () => {
96
+ const { GET } = await import('../setup-github-app-callback')
97
+ const ctx = makeCtx(null)
98
+ const res = await (GET as Function)(ctx)
99
+
100
+ expect(res.status).toBe(302)
101
+ expect(res.headers.get('location')).toContain('github-app-error=missing_code')
102
+ expect(ctx.cookies.set).not.toHaveBeenCalled()
103
+ })
104
+
105
+ it('leitet mit github-app-error=exchange_failed weiter wenn GitHub-API fehlschlägt', async () => {
106
+ vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 422, json: async () => ({}) } as Response)
107
+
108
+ const { GET } = await import('../setup-github-app-callback')
109
+ const ctx = makeCtx('badcode')
110
+ const res = await (GET as Function)(ctx)
111
+
112
+ expect(res.status).toBe(302)
113
+ expect(res.headers.get('location')).toContain('github-app-error=exchange_failed')
114
+ expect(ctx.cookies.set).not.toHaveBeenCalled()
115
+ })
116
+
117
+ it('Redirect-URL enthält den echten Host aus x-forwarded-host', async () => {
118
+ vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => GITHUB_RESPONSE } as Response)
119
+
120
+ const { GET } = await import('../setup-github-app-callback')
121
+ const ctx = makeCtx('abc123', { 'x-forwarded-host': 'meine-website.de', 'x-forwarded-proto': 'https' })
122
+ const res = await (GET as Function)(ctx)
123
+
124
+ expect(res.headers.get('location')).toBe('https://meine-website.de/admin')
125
+ })
126
+ })
127
+
128
+ describe('setup-github-app-callback – adminPath', () => {
129
+ beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
130
+ afterEach(() => { vi.restoreAllMocks(); vi.resetModules() })
131
+
132
+ it('verwendet /admin als Standard-Redirect wenn kein adminPath konfiguriert', async () => {
133
+ vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => GITHUB_RESPONSE } as Response)
134
+ ;(globalThis as any).__SETZKASTEN_CONFIG__ = undefined
135
+
136
+ const { GET } = await import('../setup-github-app-callback')
137
+ const res = await (GET as Function)(makeCtx('abc123'))
138
+
139
+ const location = new URL(res.headers.get('location') ?? '')
140
+ expect(location.pathname).toBe('/admin')
141
+ })
142
+
143
+ it('verwendet konfigurierten adminPath für den Redirect', async () => {
144
+ vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => GITHUB_RESPONSE } as Response)
145
+ ;(globalThis as any).__SETZKASTEN_CONFIG__ = { adminPath: '/cms' }
146
+
147
+ const { GET } = await import('../setup-github-app-callback')
148
+ const res = await (GET as Function)(makeCtx('abc123'))
149
+
150
+ expect(res.headers.get('location')).toContain('/cms')
151
+ })
152
+ })