@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.
Files changed (166) hide show
  1. package/dist/api-routes/_auth-guard.d.ts +27 -3
  2. package/dist/api-routes/_auth-guard.js +5 -2
  3. package/dist/api-routes/_dev-session-secret.d.ts +8 -0
  4. package/dist/api-routes/_dev-session-secret.js +8 -0
  5. package/dist/api-routes/_github-token.js +1 -1
  6. package/dist/api-routes/_role-resolver.js +6 -3
  7. package/dist/api-routes/_session-secret.d.ts +19 -0
  8. package/dist/api-routes/_session-secret.js +7 -0
  9. package/dist/api-routes/_session-signing.d.ts +45 -0
  10. package/dist/api-routes/_session-signing.js +8 -0
  11. package/dist/api-routes/_webhook-dispatcher.js +4 -4
  12. package/dist/api-routes/asset-proxy.js +1 -1
  13. package/dist/api-routes/auth-callback.js +12 -5
  14. package/dist/api-routes/auth-logout.d.ts +4 -4
  15. package/dist/api-routes/auth-logout.js +8 -2
  16. package/dist/api-routes/auth-session.d.ts +6 -0
  17. package/dist/api-routes/auth-session.js +19 -19
  18. package/dist/api-routes/auth-setzkasten-login.js +14 -7
  19. package/dist/api-routes/catalog-add.js +59 -17
  20. package/dist/api-routes/catalog-export.js +14 -4
  21. package/dist/api-routes/config.d.ts +10 -3
  22. package/dist/api-routes/config.js +26 -4
  23. package/dist/api-routes/deploy-hook.js +8 -8
  24. package/dist/api-routes/editors.d.ts +1 -1
  25. package/dist/api-routes/editors.js +5 -2
  26. package/dist/api-routes/github-proxy.js +30 -8
  27. package/dist/api-routes/global-config.js +6 -3
  28. package/dist/api-routes/history-rollback.js +31 -14
  29. package/dist/api-routes/history-version.js +8 -6
  30. package/dist/api-routes/history.js +5 -2
  31. package/dist/api-routes/icons-local.js +1 -1
  32. package/dist/api-routes/init-add-section.js +113 -47
  33. package/dist/api-routes/init-apply.js +56 -42
  34. package/dist/api-routes/init-migrate.js +43 -36
  35. package/dist/api-routes/init-scan-page.d.ts +1 -1
  36. package/dist/api-routes/init-scan-page.js +59 -13
  37. package/dist/api-routes/init-scan.js +22 -7
  38. package/dist/api-routes/migrate-to-multi.js +5 -2
  39. package/dist/api-routes/pages.js +15 -4
  40. package/dist/api-routes/section-add.js +68 -16
  41. package/dist/api-routes/section-commit-pending.js +70 -22
  42. package/dist/api-routes/section-delete.js +49 -14
  43. package/dist/api-routes/section-duplicate.js +65 -16
  44. package/dist/api-routes/section-prepare-copy.js +15 -2
  45. package/dist/api-routes/section-prepare.js +25 -4
  46. package/dist/api-routes/setup-github-app-bounce.js +15 -1
  47. package/dist/api-routes/setup-github-app-branches.js +9 -6
  48. package/dist/api-routes/setup-github-app-callback.js +24 -1
  49. package/dist/api-routes/setup-github-app-credentials.d.ts +27 -0
  50. package/dist/api-routes/setup-github-app-credentials.js +43 -0
  51. package/dist/api-routes/setup-github-app-installed.js +22 -1
  52. package/dist/api-routes/setup-github-app-repos.js +5 -2
  53. package/dist/api-routes/setup-github-app.d.ts +4 -0
  54. package/dist/api-routes/setup-github-app.js +19 -2
  55. package/dist/api-routes/updater-register.js +7 -1
  56. package/dist/api-routes/webhooks-status.js +5 -2
  57. package/dist/api-routes/webhooks-test.js +9 -8
  58. package/dist/api-routes/webhooks.js +12 -14
  59. package/dist/api-routes/websites-add.js +5 -2
  60. package/dist/api-routes/websites-remove.js +5 -2
  61. package/dist/{chunk-ZQDGGWJP.js → chunk-5KMGSFCZ.js} +2 -2
  62. package/dist/{chunk-Q3N336KR.js → chunk-CDXCYYQR.js} +29 -24
  63. package/dist/{chunk-NKDATSPA.js → chunk-DP6RTINQ.js} +1 -1
  64. package/dist/chunk-KENFINT4.js +76 -0
  65. package/dist/chunk-ONP6BRZO.js +47 -0
  66. package/dist/{chunk-INIWFKQ3.js → chunk-Q5HV47DW.js} +33 -19
  67. package/dist/chunk-QVCW6EF3.js +26 -0
  68. package/dist/{chunk-TD76R3A6.js → chunk-UHI6323G.js} +293 -174
  69. package/dist/{chunk-AM4DZXXM.js → chunk-UJAFZEX2.js} +76 -9
  70. package/package.json +12 -6
  71. package/src/api-routes/__tests__/_session-signing.test.ts +114 -0
  72. package/src/api-routes/__tests__/_session-test-helper.ts +27 -0
  73. package/src/api-routes/__tests__/add-section-helpers.test.ts +59 -25
  74. package/src/api-routes/__tests__/auth-guard.test.ts +46 -7
  75. package/src/api-routes/__tests__/catalog-api.test.ts +14 -6
  76. package/src/api-routes/__tests__/commit-trailers.test.ts +5 -5
  77. package/src/api-routes/__tests__/deferred-operations.test.ts +9 -12
  78. package/src/api-routes/__tests__/deploy-hook.test.ts +3 -8
  79. package/src/api-routes/__tests__/feature-gate.test.ts +1 -1
  80. package/src/api-routes/__tests__/github-cache.test.ts +1 -1
  81. package/src/api-routes/__tests__/github-token.test.ts +1 -1
  82. package/src/api-routes/__tests__/global-config-theme.test.ts +4 -4
  83. package/src/api-routes/__tests__/history-rollback.test.ts +6 -3
  84. package/src/api-routes/__tests__/history.test.ts +9 -6
  85. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +11 -3
  86. package/src/api-routes/__tests__/migrate-to-multi.test.ts +5 -1
  87. package/src/api-routes/__tests__/pages-meta-store.test.ts +10 -5
  88. package/src/api-routes/__tests__/pages.test.ts +7 -2
  89. package/src/api-routes/__tests__/patch-page-file.test.ts +71 -19
  90. package/src/api-routes/__tests__/route-registry.test.ts +11 -18
  91. package/src/api-routes/__tests__/scan-page-helpers.test.ts +13 -10
  92. package/src/api-routes/__tests__/section-management.test.ts +28 -28
  93. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +58 -16
  94. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +4 -5
  95. package/src/api-routes/__tests__/setup-github-app.test.ts +27 -7
  96. package/src/api-routes/__tests__/storage-config-for-request.test.ts +83 -0
  97. package/src/api-routes/__tests__/updater-register.test.ts +230 -0
  98. package/src/api-routes/__tests__/webhook-signing.test.ts +1 -1
  99. package/src/api-routes/__tests__/webhooks.test.ts +19 -7
  100. package/src/api-routes/__tests__/websites-add.test.ts +2 -1
  101. package/src/api-routes/__tests__/websites-remove.test.ts +2 -1
  102. package/src/api-routes/_auth-guard.ts +47 -15
  103. package/src/api-routes/_commit-trailers.ts +3 -2
  104. package/src/api-routes/_dev-session-secret.ts +79 -0
  105. package/src/api-routes/_github-token.ts +1 -1
  106. package/src/api-routes/_pages-meta-store.ts +2 -2
  107. package/src/api-routes/_role-resolver.ts +7 -5
  108. package/src/api-routes/_session-secret.ts +46 -0
  109. package/src/api-routes/_session-signing.ts +135 -0
  110. package/src/api-routes/_vercel-origin.ts +2 -6
  111. package/src/api-routes/_webhook-dispatcher.ts +12 -16
  112. package/src/api-routes/_website-resolver.ts +3 -10
  113. package/src/api-routes/auth-callback.ts +9 -5
  114. package/src/api-routes/auth-login.ts +5 -3
  115. package/src/api-routes/auth-logout.ts +18 -1
  116. package/src/api-routes/auth-session.ts +13 -21
  117. package/src/api-routes/auth-setzkasten-login.ts +12 -9
  118. package/src/api-routes/catalog-add.ts +89 -31
  119. package/src/api-routes/catalog-export.ts +30 -10
  120. package/src/api-routes/config.ts +39 -6
  121. package/src/api-routes/deploy-hook.ts +13 -11
  122. package/src/api-routes/editors.ts +33 -22
  123. package/src/api-routes/github-proxy.ts +25 -11
  124. package/src/api-routes/global-config.ts +103 -18
  125. package/src/api-routes/history-rollback.ts +41 -14
  126. package/src/api-routes/history-version.ts +5 -6
  127. package/src/api-routes/history.ts +3 -3
  128. package/src/api-routes/icons-local.ts +2 -2
  129. package/src/api-routes/init-add-section.ts +174 -79
  130. package/src/api-routes/init-apply.ts +71 -56
  131. package/src/api-routes/init-migrate.ts +54 -48
  132. package/src/api-routes/init-scan-page.ts +77 -30
  133. package/src/api-routes/init-scan.ts +19 -11
  134. package/src/api-routes/pages.ts +16 -11
  135. package/src/api-routes/section-add.ts +98 -27
  136. package/src/api-routes/section-commit-pending.ts +87 -34
  137. package/src/api-routes/section-delete.ts +76 -27
  138. package/src/api-routes/section-duplicate.ts +95 -28
  139. package/src/api-routes/section-management.ts +3 -7
  140. package/src/api-routes/section-prepare-copy.ts +29 -8
  141. package/src/api-routes/section-prepare.ts +38 -10
  142. package/src/api-routes/setup-github-app-bounce.ts +7 -1
  143. package/src/api-routes/setup-github-app-branches.ts +6 -7
  144. package/src/api-routes/setup-github-app-callback.ts +18 -1
  145. package/src/api-routes/setup-github-app-credentials.ts +55 -0
  146. package/src/api-routes/setup-github-app-installed.ts +12 -1
  147. package/src/api-routes/setup-github-app-repos.ts +2 -3
  148. package/src/api-routes/setup-github-app.ts +14 -5
  149. package/src/api-routes/updater-check.ts +6 -4
  150. package/src/api-routes/updater-register.ts +34 -20
  151. package/src/api-routes/updater-transfer.ts +8 -6
  152. package/src/api-routes/updater-unbind.ts +14 -10
  153. package/src/api-routes/webhooks-test.ts +9 -11
  154. package/src/api-routes/webhooks.ts +15 -19
  155. package/src/init/__tests__/page-level.test.ts +279 -105
  156. package/src/init/__tests__/page-list-coverage.test.ts +70 -70
  157. package/src/init/__tests__/patcher-child-component.test.ts +12 -3
  158. package/src/init/__tests__/patcher-edge-cases.test.ts +47 -23
  159. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +16 -6
  160. package/src/init/__tests__/patcher-page-mode.test.ts +30 -20
  161. package/src/init/__tests__/section-pipeline.test.ts +53 -19
  162. package/src/init/astro-config-patcher.ts +4 -18
  163. package/src/init/astro-detector.ts +2 -7
  164. package/src/init/astro-section-analyzer-v2.ts +475 -193
  165. package/src/init/field-label-enricher.ts +6 -6
  166. package/src/init/template-patcher-v2.ts +218 -97
@@ -8,13 +8,13 @@
8
8
  * 3. duplicateInPageConfig: adds duplicate entry after the original
9
9
  */
10
10
 
11
- import { describe, it, expect } from 'vitest'
11
+ import { describe, expect, it } from 'vitest'
12
12
  import {
13
- removeFromPageConfig,
14
- generateDuplicateKey,
13
+ addToPageConfig,
15
14
  duplicateInPageConfig,
16
15
  generateAddKey,
17
- addToPageConfig,
16
+ generateDuplicateKey,
17
+ removeFromPageConfig,
18
18
  } from '../section-management'
19
19
 
20
20
  // ---------------------------------------------------------------------------
@@ -36,7 +36,7 @@ const pageConfig = {
36
36
  describe('removeFromPageConfig', () => {
37
37
  it('removes the matching section entry', () => {
38
38
  const result = removeFromPageConfig(pageConfig, 'features')
39
- expect(result.sections.map(s => s.key)).toEqual(['hero', 'cta'])
39
+ expect(result.sections.map((s) => s.key)).toEqual(['hero', 'cta'])
40
40
  })
41
41
 
42
42
  it('re-numbers order after removal', () => {
@@ -84,7 +84,9 @@ describe('generateDuplicateKey', () => {
84
84
  })
85
85
 
86
86
  it('does not conflict with unrelated keys containing copy', () => {
87
- expect(generateDuplicateKey(['features', 'features--copycat'], 'features')).toBe('features--copy')
87
+ expect(generateDuplicateKey(['features', 'features--copycat'], 'features')).toBe(
88
+ 'features--copy',
89
+ )
88
90
  })
89
91
  })
90
92
 
@@ -95,13 +97,13 @@ describe('generateDuplicateKey', () => {
95
97
  describe('duplicateInPageConfig', () => {
96
98
  it('inserts duplicate entry immediately after the original', () => {
97
99
  const result = duplicateInPageConfig(pageConfig, 'hero', 'hero--copy')
98
- const keys = result.sections.map(s => s.key)
100
+ const keys = result.sections.map((s) => s.key)
99
101
  expect(keys).toEqual(['hero', 'hero--copy', 'features', 'cta'])
100
102
  })
101
103
 
102
104
  it('new entry is enabled regardless of original enabled state', () => {
103
105
  const result = duplicateInPageConfig(pageConfig, 'cta', 'cta--copy')
104
- const copy = result.sections.find(s => s.key === 'cta--copy')!
106
+ const copy = result.sections.find((s) => s.key === 'cta--copy')!
105
107
  expect(copy.enabled).toBe(true)
106
108
  })
107
109
 
@@ -112,12 +114,10 @@ describe('duplicateInPageConfig', () => {
112
114
 
113
115
  it('preserves type field from original if present', () => {
114
116
  const withType = {
115
- sections: [
116
- { key: 'hero--about', type: 'hero', enabled: true, order: 0 },
117
- ],
117
+ sections: [{ key: 'hero--about', type: 'hero', enabled: true, order: 0 }],
118
118
  }
119
119
  const result = duplicateInPageConfig(withType, 'hero--about', 'hero--about--copy')
120
- const copy = result.sections.find(s => s.key === 'hero--about--copy')!
120
+ const copy = result.sections.find((s) => s.key === 'hero--about--copy')!
121
121
  expect((copy as any).type).toBe('hero')
122
122
  })
123
123
 
@@ -165,7 +165,7 @@ describe('generateAddKey', () => {
165
165
  describe('delete workflow', () => {
166
166
  it('removes section and renumbers order', () => {
167
167
  const after = removeFromPageConfig(pageConfig, 'features')
168
- expect(after.sections.map(s => s.key)).toEqual(['hero', 'cta'])
168
+ expect(after.sections.map((s) => s.key)).toEqual(['hero', 'cta'])
169
169
  expect(after.sections[0]!.order).toBe(0)
170
170
  expect(after.sections[1]!.order).toBe(1)
171
171
  })
@@ -184,11 +184,11 @@ describe('delete workflow', () => {
184
184
 
185
185
  describe('duplicate workflow', () => {
186
186
  it('generates key and inserts copy right after original', () => {
187
- const existingKeys = pageConfig.sections.map(s => s.key)
187
+ const existingKeys = pageConfig.sections.map((s) => s.key)
188
188
  const newKey = generateDuplicateKey(existingKeys, 'hero')
189
189
  const after = duplicateInPageConfig(pageConfig, 'hero', newKey)
190
190
  expect(newKey).toBe('hero--copy')
191
- expect(after.sections.map(s => s.key)).toEqual(['hero', 'hero--copy', 'features', 'cta'])
191
+ expect(after.sections.map((s) => s.key)).toEqual(['hero', 'hero--copy', 'features', 'cta'])
192
192
  after.sections.forEach((s, i) => expect(s.order).toBe(i))
193
193
  })
194
194
 
@@ -199,11 +199,11 @@ describe('duplicate workflow', () => {
199
199
  { key: 'hero--copy', enabled: true, order: 1 },
200
200
  ],
201
201
  }
202
- const existingKeys = withCopy.sections.map(s => s.key)
202
+ const existingKeys = withCopy.sections.map((s) => s.key)
203
203
  const newKey = generateDuplicateKey(existingKeys, 'hero')
204
204
  expect(newKey).toBe('hero--copy2')
205
205
  const after = duplicateInPageConfig(withCopy, 'hero', newKey)
206
- expect(after.sections.map(s => s.key)).toEqual(['hero', 'hero--copy2', 'hero--copy'])
206
+ expect(after.sections.map((s) => s.key)).toEqual(['hero', 'hero--copy2', 'hero--copy'])
207
207
  })
208
208
  })
209
209
 
@@ -214,30 +214,30 @@ describe('duplicate workflow', () => {
214
214
  describe('addToPageConfig', () => {
215
215
  it('appends new entry at the end', () => {
216
216
  const result = addToPageConfig(pageConfig, 'testimonials', 'testimonials')
217
- expect(result.sections.map(s => s.key)).toEqual(['hero', 'features', 'cta', 'testimonials'])
217
+ expect(result.sections.map((s) => s.key)).toEqual(['hero', 'features', 'cta', 'testimonials'])
218
218
  })
219
219
 
220
220
  it('new entry is enabled by default', () => {
221
221
  const result = addToPageConfig(pageConfig, 'testimonials', 'testimonials')
222
- const entry = result.sections.find(s => s.key === 'testimonials')!
222
+ const entry = result.sections.find((s) => s.key === 'testimonials')!
223
223
  expect(entry.enabled).toBe(true)
224
224
  })
225
225
 
226
226
  it('sets type field when key differs from type', () => {
227
227
  const result = addToPageConfig(pageConfig, 'hero--2', 'hero')
228
- const entry = result.sections.find(s => s.key === 'hero--2')!
228
+ const entry = result.sections.find((s) => s.key === 'hero--2')!
229
229
  expect((entry as any).type).toBe('hero')
230
230
  })
231
231
 
232
232
  it('omits type field when key equals type', () => {
233
233
  const result = addToPageConfig(pageConfig, 'testimonials', 'testimonials')
234
- const entry = result.sections.find(s => s.key === 'testimonials')!
234
+ const entry = result.sections.find((s) => s.key === 'testimonials')!
235
235
  expect((entry as any).type).toBeUndefined()
236
236
  })
237
237
 
238
238
  it('assigns correct order (after last existing)', () => {
239
239
  const result = addToPageConfig(pageConfig, 'testimonials', 'testimonials')
240
- const entry = result.sections.find(s => s.key === 'testimonials')!
240
+ const entry = result.sections.find((s) => s.key === 'testimonials')!
241
241
  expect(entry.order).toBe(3)
242
242
  })
243
243
 
@@ -255,22 +255,22 @@ describe('addToPageConfig', () => {
255
255
 
256
256
  describe('section-prepare contract', () => {
257
257
  it('generates key and returns updated config without touching GitHub', () => {
258
- const existingKeys = pageConfig.sections.map(s => s.key)
259
- const newKey = generateAddKey(existingKeys, 'features') // 'features' is taken
258
+ const existingKeys = pageConfig.sections.map((s) => s.key)
259
+ const newKey = generateAddKey(existingKeys, 'features') // 'features' is taken
260
260
  expect(newKey).toBe('features--2')
261
261
  const updated = addToPageConfig(pageConfig, newKey, 'features')
262
262
  expect(updated.sections).toHaveLength(4)
263
- const entry = updated.sections.find(s => s.key === 'features--2')!
263
+ const entry = updated.sections.find((s) => s.key === 'features--2')!
264
264
  expect((entry as any).type).toBe('features')
265
265
  expect(entry.enabled).toBe(true)
266
266
  })
267
267
 
268
268
  it('new section key is unique even after two pending adds of same type', () => {
269
- let keys = pageConfig.sections.map(s => s.key)
270
- const key1 = generateAddKey(keys, 'hero') // hero is taken → hero--2
269
+ let keys = pageConfig.sections.map((s) => s.key)
270
+ const key1 = generateAddKey(keys, 'hero') // hero is taken → hero--2
271
271
  expect(key1).toBe('hero--2')
272
272
  keys = [...keys, key1]
273
- const key2 = generateAddKey(keys, 'hero') // hero + hero--2 taken → hero--3
273
+ const key2 = generateAddKey(keys, 'hero') // hero + hero--2 taken → hero--3
274
274
  expect(key2).toBe('hero--3')
275
275
  })
276
276
 
@@ -8,17 +8,25 @@
8
8
  * @vitest-environment node
9
9
  */
10
10
 
11
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12
+ import { makeTestSessionCookie } from './_session-test-helper'
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Minimal Astro context mock
15
16
  // ---------------------------------------------------------------------------
16
17
 
17
- function makeCookies() {
18
- const jar: Record<string, string> = {}
18
+ const ADMIN_SESSION = makeTestSessionCookie({
19
+ user: { id: 'admin-1', email: 'admin@example.com', role: 'admin', provider: 'github' },
20
+ expiresAt: Date.now() + 60 * 60 * 1000,
21
+ })
22
+
23
+ function makeCookies(seed: Record<string, string> = {}) {
24
+ const jar: Record<string, string> = { ...seed }
19
25
  return {
20
- set: vi.fn((name: string, value: string) => { jar[name] = value }),
21
- get: vi.fn((name: string) => jar[name] ? { value: jar[name] } : undefined),
26
+ set: vi.fn((name: string, value: string) => {
27
+ jar[name] = value
28
+ }),
29
+ get: vi.fn((name: string) => (jar[name] ? { value: jar[name] } : undefined)),
22
30
  _jar: jar,
23
31
  }
24
32
  }
@@ -26,8 +34,16 @@ function makeCookies() {
26
34
  function makeCtx(code: string | null, headers: Record<string, string> = {}) {
27
35
  const search = code ? `?code=${code}` : ''
28
36
  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() }
37
+ const request = new Request(url, {
38
+ headers: {
39
+ 'x-forwarded-host': 'setzkasten.vercel.app',
40
+ 'x-forwarded-proto': 'https',
41
+ ...headers,
42
+ },
43
+ })
44
+ // Tests need an admin session — the route refuses unauthenticated
45
+ // requests post-H1 (was an unauthenticated endpoint before).
46
+ return { url, request, cookies: makeCookies({ setzkasten_session: ADMIN_SESSION }) }
31
47
  }
32
48
 
33
49
  const GITHUB_RESPONSE = {
@@ -44,8 +60,13 @@ const GITHUB_RESPONSE = {
44
60
  // ---------------------------------------------------------------------------
45
61
 
46
62
  describe('setup-github-app-callback', () => {
47
- beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
48
- afterEach(() => { vi.restoreAllMocks(); vi.resetModules() })
63
+ beforeEach(() => {
64
+ vi.stubGlobal('fetch', vi.fn())
65
+ })
66
+ afterEach(() => {
67
+ vi.restoreAllMocks()
68
+ vi.resetModules()
69
+ })
49
70
 
50
71
  it('setzt Cookie und leitet zum Standard-adminPath weiter bei erfolgreichem Code-Austausch', async () => {
51
72
  vi.mocked(fetch).mockResolvedValueOnce({
@@ -103,7 +124,11 @@ describe('setup-github-app-callback', () => {
103
124
  })
104
125
 
105
126
  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)
127
+ vi.mocked(fetch).mockResolvedValueOnce({
128
+ ok: false,
129
+ status: 422,
130
+ json: async () => ({}),
131
+ } as Response)
107
132
 
108
133
  const { GET } = await import('../setup-github-app-callback')
109
134
  const ctx = makeCtx('badcode')
@@ -115,10 +140,16 @@ describe('setup-github-app-callback', () => {
115
140
  })
116
141
 
117
142
  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)
143
+ vi.mocked(fetch).mockResolvedValueOnce({
144
+ ok: true,
145
+ json: async () => GITHUB_RESPONSE,
146
+ } as Response)
119
147
 
120
148
  const { GET } = await import('../setup-github-app-callback')
121
- const ctx = makeCtx('abc123', { 'x-forwarded-host': 'meine-website.de', 'x-forwarded-proto': 'https' })
149
+ const ctx = makeCtx('abc123', {
150
+ 'x-forwarded-host': 'meine-website.de',
151
+ 'x-forwarded-proto': 'https',
152
+ })
122
153
  const res = await (GET as Function)(ctx)
123
154
 
124
155
  expect(res.headers.get('location')).toBe('https://meine-website.de/admin')
@@ -126,11 +157,19 @@ describe('setup-github-app-callback', () => {
126
157
  })
127
158
 
128
159
  describe('setup-github-app-callback – adminPath', () => {
129
- beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
130
- afterEach(() => { vi.restoreAllMocks(); vi.resetModules() })
160
+ beforeEach(() => {
161
+ vi.stubGlobal('fetch', vi.fn())
162
+ })
163
+ afterEach(() => {
164
+ vi.restoreAllMocks()
165
+ vi.resetModules()
166
+ })
131
167
 
132
168
  it('verwendet /admin als Standard-Redirect wenn kein adminPath konfiguriert', async () => {
133
- vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => GITHUB_RESPONSE } as Response)
169
+ vi.mocked(fetch).mockResolvedValueOnce({
170
+ ok: true,
171
+ json: async () => GITHUB_RESPONSE,
172
+ } as Response)
134
173
  ;(globalThis as any).__SETZKASTEN_CONFIG__ = undefined
135
174
 
136
175
  const { GET } = await import('../setup-github-app-callback')
@@ -141,7 +180,10 @@ describe('setup-github-app-callback – adminPath', () => {
141
180
  })
142
181
 
143
182
  it('verwendet konfigurierten adminPath für den Redirect', async () => {
144
- vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => GITHUB_RESPONSE } as Response)
183
+ vi.mocked(fetch).mockResolvedValueOnce({
184
+ ok: true,
185
+ json: async () => GITHUB_RESPONSE,
186
+ } as Response)
145
187
  ;(globalThis as any).__SETZKASTEN_CONFIG__ = { adminPath: '/cms' }
146
188
 
147
189
  const { GET } = await import('../setup-github-app-callback')
@@ -8,7 +8,8 @@
8
8
  */
9
9
 
10
10
  import { generateKeyPairSync } from 'node:crypto'
11
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12
+ import { makeTestSessionCookie } from './_session-test-helper'
12
13
 
13
14
  const { privateKey: KEY } = generateKeyPairSync('rsa', { modulusLength: 2048 })
14
15
  const PRIVATE_KEY_PEM = KEY.export({ type: 'pkcs8', format: 'pem' }) as string
@@ -17,7 +18,7 @@ const PRIVATE_KEY_PEM = KEY.export({ type: 'pkcs8', format: 'pem' }) as string
17
18
  // tests used to pass would now be rejected before reaching the GitHub layer.
18
19
  // Replace it with a real serialized admin session and let callers opt out
19
20
  // via session: null when they want to exercise the unauthenticated branch.
20
- const ADMIN_SESSION = JSON.stringify({
21
+ const ADMIN_SESSION = makeTestSessionCookie({
21
22
  user: {
22
23
  id: 'u1',
23
24
  email: 'admin@example.com',
@@ -40,9 +41,7 @@ function makeCookies(session?: string) {
40
41
  }
41
42
 
42
43
  function makeCtx(opts: { session?: string; query?: string } = {}) {
43
- const url = new URL(
44
- `https://localhost/api/setzkasten/setup/github-app/repos${opts.query ?? ''}`,
45
- )
44
+ const url = new URL(`https://localhost/api/setzkasten/setup/github-app/repos${opts.query ?? ''}`)
46
45
  return {
47
46
  url,
48
47
  cookies: makeCookies(opts.session),
@@ -9,17 +9,29 @@
9
9
  * POST 5. Ungültige Credentials (Token-Fetch schlägt fehl) → { ok: false, error }
10
10
  */
11
11
 
12
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
13
12
  import { generateKeyPairSync } from 'node:crypto'
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
14
+ import { makeTestSessionCookie } from './_session-test-helper'
14
15
 
15
16
  const { privateKey: TEST_KEY } = generateKeyPairSync('rsa', { modulusLength: 2048 })
16
17
  const TEST_PEM = TEST_KEY.export({ type: 'pkcs8', format: 'pem' }) as string
17
18
 
19
+ const ADMIN_SESSION = makeTestSessionCookie({
20
+ user: { id: 'a', email: 'admin@example.com', role: 'admin', provider: 'github' },
21
+ expiresAt: Date.now() + 60_000,
22
+ })
23
+
24
+ function makeCookies() {
25
+ return {
26
+ get: (name: string) => (name === 'setzkasten_session' ? { value: ADMIN_SESSION } : undefined),
27
+ }
28
+ }
29
+
18
30
  type MockRequest = { json: () => Promise<unknown> }
19
- type MockContext = { request: MockRequest }
31
+ type MockContext = { request: MockRequest; cookies: ReturnType<typeof makeCookies> }
20
32
 
21
33
  function makeCtx(body: unknown): MockContext {
22
- return { request: { json: async () => body } }
34
+ return { request: { json: async () => body }, cookies: makeCookies() }
23
35
  }
24
36
 
25
37
  function setEnv(vars: Record<string, string | undefined>) {
@@ -29,23 +41,31 @@ function setEnv(vars: Record<string, string | undefined>) {
29
41
 
30
42
  describe('setup-github-app GET', () => {
31
43
  afterEach(() => {
32
- setEnv({ GITHUB_APP_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined, GITHUB_APP_INSTALLATION_ID: undefined })
44
+ setEnv({
45
+ GITHUB_APP_ID: undefined,
46
+ GITHUB_APP_PRIVATE_KEY: undefined,
47
+ GITHUB_APP_INSTALLATION_ID: undefined,
48
+ })
33
49
  vi.resetModules()
34
50
  })
35
51
 
36
52
  it('gibt configured: false zurück wenn App nicht konfiguriert', async () => {
37
53
  const { GET } = await import('../setup-github-app')
38
- const res = await (GET as Function)({})
54
+ const res = await (GET as Function)({ cookies: makeCookies() })
39
55
  const body = await res.json()
40
56
  expect(res.status).toBe(200)
41
57
  expect(body.configured).toBe(false)
42
58
  })
43
59
 
44
60
  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' })
61
+ setEnv({
62
+ GITHUB_APP_ID: '99999',
63
+ GITHUB_APP_PRIVATE_KEY: TEST_PEM,
64
+ GITHUB_APP_INSTALLATION_ID: '11111',
65
+ })
46
66
  vi.resetModules()
47
67
  const { GET } = await import('../setup-github-app')
48
- const res = await (GET as Function)({})
68
+ const res = await (GET as Function)({ cookies: makeCookies() })
49
69
  const body = await res.json()
50
70
  expect(res.status).toBe(200)
51
71
  expect(body.configured).toBe(true)
@@ -75,4 +75,87 @@ describe('resolveStorageConfigForRequest', () => {
75
75
  expect(result?.repo).toBe('forced')
76
76
  expect(result?.branch).toBe('main')
77
77
  })
78
+
79
+ // ---------------------------------------------------------------------
80
+ // projectPrefix inheritance (security regression — section template
81
+ // patcher used to fail silently because resolver hardcoded prefix='').
82
+ // ---------------------------------------------------------------------
83
+
84
+ it('inherits the build-time projectPrefix when body owner/repo match the build target', async () => {
85
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = {
86
+ owner: 'acme',
87
+ repo: 'site-a',
88
+ branch: 'main',
89
+ contentPath: 'content',
90
+ assetsPath: 'public/images',
91
+ projectPrefix: 'apps/website',
92
+ }
93
+ __resetWebsiteResolverForTests(null)
94
+ const req = new Request('https://cms.example.com/x')
95
+
96
+ const result = await resolveStorageConfigForRequest(req, {
97
+ owner: 'acme',
98
+ repo: 'site-a',
99
+ })
100
+
101
+ expect(result?.projectPrefix).toBe('apps/website')
102
+ })
103
+
104
+ it('does NOT leak the build-time prefix to a body override targeting a different repo', async () => {
105
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = {
106
+ owner: 'acme',
107
+ repo: 'site-a',
108
+ branch: 'main',
109
+ contentPath: 'content',
110
+ assetsPath: 'public/images',
111
+ projectPrefix: 'apps/website',
112
+ }
113
+ __resetWebsiteResolverForTests(null)
114
+ const req = new Request('https://cms.example.com/x')
115
+
116
+ const result = await resolveStorageConfigForRequest(req, {
117
+ owner: 'attacker',
118
+ repo: 'evil-fork',
119
+ branch: 'main',
120
+ })
121
+
122
+ // Attacker repo gets an empty prefix — refusing to compose
123
+ // `apps/website/...` paths against an unrelated repo.
124
+ expect(result?.owner).toBe('attacker')
125
+ expect(result?.projectPrefix).toBe('')
126
+ })
127
+
128
+ it('inherits the prefix only when the resolved-website owner/repo match the build target', async () => {
129
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = {
130
+ owner: 'acme',
131
+ repo: 'site-a',
132
+ branch: 'main',
133
+ contentPath: 'content',
134
+ assetsPath: 'public/images',
135
+ projectPrefix: 'apps/website',
136
+ }
137
+ __resetWebsiteResolverForTests({
138
+ mode: 'multi',
139
+ registry: makeRegistry([
140
+ ENTRY,
141
+ {
142
+ ...ENTRY,
143
+ id: 'site-b',
144
+ repo: 'acme/site-b',
145
+ },
146
+ ]),
147
+ })
148
+
149
+ const reqMatch = new Request('https://cms.example.com/x', {
150
+ headers: { 'x-sk-website': 'site-a' },
151
+ })
152
+ const resMatch = await resolveStorageConfigForRequest(reqMatch)
153
+ expect(resMatch?.projectPrefix).toBe('apps/website')
154
+
155
+ const reqOther = new Request('https://cms.example.com/x', {
156
+ headers: { 'x-sk-website': 'site-b' },
157
+ })
158
+ const resOther = await resolveStorageConfigForRequest(reqOther)
159
+ expect(resOther?.projectPrefix).toBe('')
160
+ })
78
161
  })