@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,196 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { generateKeyPairSync } from 'node:crypto'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
9
+ const PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
10
+
11
+ const ADMIN_SESSION = JSON.stringify({
12
+ user: { id: 'u1', email: 'admin@example.com', role: 'admin', provider: 'github' },
13
+ expiresAt: Date.now() + 60 * 60 * 1000,
14
+ })
15
+
16
+ const EDITOR_SESSION = JSON.stringify({
17
+ user: { id: 'u2', email: 'editor@example.com', role: 'editor', provider: 'google' },
18
+ expiresAt: Date.now() + 60 * 60 * 1000,
19
+ })
20
+
21
+ function makeCtx(body: unknown, sessionValue: string | null = ADMIN_SESSION) {
22
+ const request = new Request('https://cms.example.com/api/setzkasten/history/rollback', {
23
+ method: 'POST',
24
+ body: JSON.stringify(body),
25
+ headers: { 'content-type': 'application/json' },
26
+ })
27
+ return {
28
+ request,
29
+ cookies: {
30
+ get: vi.fn((name: string) =>
31
+ name === 'setzkasten_session' && sessionValue ? { value: sessionValue } : undefined,
32
+ ),
33
+ },
34
+ }
35
+ }
36
+
37
+ beforeEach(() => {
38
+ vi.unstubAllEnvs()
39
+ vi.stubEnv('GITHUB_APP_ID', '1')
40
+ vi.stubEnv('GITHUB_APP_INSTALLATION_ID', '111')
41
+ vi.stubEnv('GITHUB_APP_PRIVATE_KEY', PEM)
42
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-PRO-AAAAAAAA-BBBBBBBB-CCCCCCCC')
43
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = {
44
+ kind: 'github-app',
45
+ repo: 'acme/site',
46
+ branch: 'main',
47
+ appId: '1',
48
+ installationId: '111',
49
+ }
50
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
51
+ storage: {
52
+ kind: 'github-app',
53
+ repo: 'acme/site',
54
+ branch: 'main',
55
+ appId: '1',
56
+ installationId: '111',
57
+ },
58
+ }
59
+ })
60
+
61
+ afterEach(() => {
62
+ vi.restoreAllMocks()
63
+ vi.unstubAllEnvs()
64
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = undefined
65
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
66
+ })
67
+
68
+ const ROLLBACK_PATH = 'content/sections/hero.json'
69
+ const TARGET_SHA = 'b'.repeat(40)
70
+ const HEAD_SHA_FILE = 'c'.repeat(40)
71
+
72
+ describe('POST /api/setzkasten/history/rollback', () => {
73
+ it('returns 401 without a session', async () => {
74
+ const { POST } = await import('../history-rollback')
75
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(
76
+ makeCtx({ path: ROLLBACK_PATH, sha: TARGET_SHA }, null),
77
+ )
78
+ expect(res.status).toBe(401)
79
+ })
80
+
81
+ it('returns 403 for editor session', async () => {
82
+ const { POST } = await import('../history-rollback')
83
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(
84
+ makeCtx({ path: ROLLBACK_PATH, sha: TARGET_SHA }, EDITOR_SESSION),
85
+ )
86
+ expect(res.status).toBe(403)
87
+ })
88
+
89
+ it('returns 400 when path or sha missing', async () => {
90
+ const { POST } = await import('../history-rollback')
91
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({ path: ROLLBACK_PATH }))
92
+ expect(res.status).toBe(400)
93
+ })
94
+
95
+ it('writes a new commit with the historical content', async () => {
96
+ const calls: string[] = []
97
+ const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => {
98
+ const u = url instanceof URL ? url : new URL(url)
99
+ calls.push(`${init?.method ?? 'GET'} ${u.pathname}${u.search}`)
100
+ if (u.pathname.endsWith('/access_tokens')) {
101
+ return {
102
+ ok: true,
103
+ json: async () => ({
104
+ token: 'gh_mock',
105
+ expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
106
+ }),
107
+ } as Response
108
+ }
109
+ // Read at target sha
110
+ if (u.search.includes(`ref=${TARGET_SHA}`)) {
111
+ return {
112
+ ok: true,
113
+ status: 200,
114
+ json: async () => ({
115
+ content: Buffer.from('{"heading":"old"}').toString('base64'),
116
+ encoding: 'base64',
117
+ sha: 'old-blob',
118
+ }),
119
+ } as Response
120
+ }
121
+ // Read HEAD blob sha
122
+ if (u.search.includes('ref=main') && (init?.method ?? 'GET') === 'GET') {
123
+ return {
124
+ ok: true,
125
+ status: 200,
126
+ json: async () => ({ sha: HEAD_SHA_FILE }),
127
+ } as Response
128
+ }
129
+ // PUT new commit
130
+ if (init?.method === 'PUT' && u.pathname.includes('/contents/')) {
131
+ return {
132
+ ok: true,
133
+ status: 200,
134
+ json: async () => ({ commit: { sha: 'd'.repeat(40) } }),
135
+ } as Response
136
+ }
137
+ throw new Error(`unexpected URL: ${u.toString()}`)
138
+ })
139
+ vi.stubGlobal('fetch', fetchMock)
140
+
141
+ const { POST } = await import('../history-rollback')
142
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(
143
+ makeCtx({ path: ROLLBACK_PATH, sha: TARGET_SHA }),
144
+ )
145
+ expect(res.status).toBe(200)
146
+ const body = await res.json()
147
+ expect(body.ok).toBe(true)
148
+ expect(body.commitSha).toBe('d'.repeat(40))
149
+ expect(calls.some((c) => c.startsWith('PUT'))).toBe(true)
150
+ })
151
+
152
+ it('returns 409 when expectedHeadSha does not match current HEAD', async () => {
153
+ const fetchMock = vi.fn(async (url: string | URL) => {
154
+ const u = url instanceof URL ? url : new URL(url)
155
+ if (u.pathname.endsWith('/access_tokens')) {
156
+ return {
157
+ ok: true,
158
+ json: async () => ({
159
+ token: 'gh_mock',
160
+ expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
161
+ }),
162
+ } as Response
163
+ }
164
+ if (u.search.includes(`ref=${TARGET_SHA}`)) {
165
+ return {
166
+ ok: true,
167
+ status: 200,
168
+ json: async () => ({
169
+ content: Buffer.from('{"heading":"old"}').toString('base64'),
170
+ encoding: 'base64',
171
+ sha: 'old-blob',
172
+ }),
173
+ } as Response
174
+ }
175
+ // HEAD has SHA "moved-on" but client expected "expected"
176
+ return {
177
+ ok: true,
178
+ status: 200,
179
+ json: async () => ({ sha: 'moved-on-since-page-load' }),
180
+ } as Response
181
+ })
182
+ vi.stubGlobal('fetch', fetchMock)
183
+
184
+ const { POST } = await import('../history-rollback')
185
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(
186
+ makeCtx({
187
+ path: ROLLBACK_PATH,
188
+ sha: TARGET_SHA,
189
+ expectedHeadSha: 'something-else-entirely',
190
+ }),
191
+ )
192
+ expect(res.status).toBe(409)
193
+ const body = await res.json()
194
+ expect(body.code).toBe('head-moved')
195
+ })
196
+ })
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { generateKeyPairSync } from 'node:crypto'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
9
+ const PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
10
+
11
+ const ADMIN_SESSION = JSON.stringify({
12
+ user: { id: 'u1', email: 'admin@example.com', role: 'admin', provider: 'github' },
13
+ expiresAt: Date.now() + 60 * 60 * 1000,
14
+ })
15
+
16
+ const EDITOR_SESSION = JSON.stringify({
17
+ user: { id: 'u2', email: 'editor@example.com', role: 'editor', provider: 'google' },
18
+ expiresAt: Date.now() + 60 * 60 * 1000,
19
+ })
20
+
21
+ function makeCtx(searchParams: Record<string, string>, sessionValue: string | null = ADMIN_SESSION) {
22
+ const url = new URL('https://cms.example.com/api/setzkasten/history')
23
+ for (const [k, v] of Object.entries(searchParams)) url.searchParams.set(k, v)
24
+ const request = new Request(url, { method: 'GET' })
25
+ return {
26
+ request,
27
+ url,
28
+ cookies: {
29
+ get: vi.fn((name: string) =>
30
+ name === 'setzkasten_session' && sessionValue ? { value: sessionValue } : undefined,
31
+ ),
32
+ },
33
+ }
34
+ }
35
+
36
+ beforeEach(() => {
37
+ vi.unstubAllEnvs()
38
+ vi.stubEnv('GITHUB_APP_ID', '1')
39
+ vi.stubEnv('GITHUB_APP_INSTALLATION_ID', '111')
40
+ vi.stubEnv('GITHUB_APP_PRIVATE_KEY', PEM)
41
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-PRO-AAAAAAAA-BBBBBBBB-CCCCCCCC')
42
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = {
43
+ kind: 'github-app',
44
+ repo: 'acme/site',
45
+ branch: 'main',
46
+ appId: '1',
47
+ installationId: '111',
48
+ }
49
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
50
+ storage: {
51
+ kind: 'github-app',
52
+ repo: 'acme/site',
53
+ branch: 'main',
54
+ appId: '1',
55
+ installationId: '111',
56
+ },
57
+ }
58
+ // Reset history cache between tests so cachedFetch doesn't pollute state.
59
+ return import('../_github-cache').then((m) => {
60
+ // We don't know the keys, but the cache is keyed by route+args; tests
61
+ // pick fresh paths, so old entries simply expire. No reset API exposed.
62
+ void m
63
+ })
64
+ })
65
+
66
+ afterEach(() => {
67
+ vi.restoreAllMocks()
68
+ vi.unstubAllEnvs()
69
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = undefined
70
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
71
+ })
72
+
73
+ const SAMPLE_COMMIT = {
74
+ sha: 'a'.repeat(40),
75
+ commit: {
76
+ author: { name: 'Maria', email: 'maria@example.com', date: '2026-05-01T12:00:00Z' },
77
+ message: `Update hero\n\nCo-authored-by: Editor <editor@example.com>`,
78
+ },
79
+ author: { avatar_url: 'https://avatars.example.com/maria.png' },
80
+ }
81
+
82
+ describe('GET /api/setzkasten/history', () => {
83
+ it('returns 401 without a session', async () => {
84
+ const { GET } = await import('../history')
85
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(
86
+ makeCtx({ path: 'content/sections/hero.json' }, null),
87
+ )
88
+ expect(res.status).toBe(401)
89
+ })
90
+
91
+ it('returns 403 for editor (admin-only)', async () => {
92
+ const { GET } = await import('../history')
93
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(
94
+ makeCtx({ path: 'content/sections/hero.json' }, EDITOR_SESSION),
95
+ )
96
+ expect(res.status).toBe(403)
97
+ })
98
+
99
+ it('returns 400 when path is missing', async () => {
100
+ const { GET } = await import('../history')
101
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx({}))
102
+ expect(res.status).toBe(400)
103
+ })
104
+
105
+ it('returns parsed commits with co-authors and short sha', async () => {
106
+ const fetchMock = vi.fn(async (url: string | URL) => {
107
+ const u = url instanceof URL ? url : new URL(url)
108
+ if (u.pathname.endsWith('/access_tokens')) {
109
+ return {
110
+ ok: true,
111
+ json: async () => ({
112
+ token: 'gh_mock',
113
+ expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
114
+ }),
115
+ } as Response
116
+ }
117
+ if (u.pathname.endsWith('/commits')) {
118
+ return { ok: true, status: 200, json: async () => [SAMPLE_COMMIT] } as Response
119
+ }
120
+ throw new Error(`unexpected URL: ${u.toString()}`)
121
+ })
122
+ vi.stubGlobal('fetch', fetchMock)
123
+
124
+ const { GET } = await import('../history')
125
+ // Use a fresh path each time to avoid cache hits from earlier tests
126
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(
127
+ makeCtx({ path: 'content/sections/hero-' + Date.now() + '.json' }),
128
+ )
129
+ expect(res.status).toBe(200)
130
+ const body = await res.json()
131
+ expect(body.commits).toHaveLength(1)
132
+ expect(body.commits[0]).toMatchObject({
133
+ sha: 'a'.repeat(40),
134
+ shortSha: 'aaaaaaa',
135
+ authorName: 'Maria',
136
+ authorEmail: 'maria@example.com',
137
+ message: 'Update hero',
138
+ })
139
+ expect(body.commits[0].coAuthors).toEqual([
140
+ { name: 'Editor', email: 'editor@example.com' },
141
+ ])
142
+ })
143
+
144
+ it('returns empty list when GitHub returns 404 for the path', async () => {
145
+ const fetchMock = vi.fn(async (url: string | URL) => {
146
+ const u = url instanceof URL ? url : new URL(url)
147
+ if (u.pathname.endsWith('/access_tokens')) {
148
+ return {
149
+ ok: true,
150
+ json: async () => ({
151
+ token: 'gh_mock',
152
+ expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
153
+ }),
154
+ } as Response
155
+ }
156
+ return { ok: false, status: 404, json: async () => ({}) } as Response
157
+ })
158
+ vi.stubGlobal('fetch', fetchMock)
159
+
160
+ const { GET } = await import('../history')
161
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(
162
+ makeCtx({ path: 'content/sections/missing-' + Date.now() + '.json' }),
163
+ )
164
+ expect(res.status).toBe(200)
165
+ const body = await res.json()
166
+ expect(body.commits).toEqual([])
167
+ })
168
+ })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tests for resolveFullConfig() — the helper that scan-page uses to read the
3
+ * full Setzkasten config in API-route context.
4
+ *
5
+ * Why this matters: when /api/setzkasten/init/scan-page is invoked as a
6
+ * standalone serverless function (Vercel cold-start), the `page-ssr`
7
+ * injectScript that sets globalThis.__SETZKASTEN_FULL_CONFIG__ never ran.
8
+ * Without a fallback, managedSections becomes an empty Map and every
9
+ * already-adopted section (e.g. _layout_header, _layout_footer) is offered
10
+ * for re-adoption. The fix: a Vite build-time define inlines the config
11
+ * into compiled API routes; resolveFullConfig() prefers the build-time
12
+ * constant and falls back to globalThis for local dev / tests.
13
+ *
14
+ * @vitest-environment node
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
18
+ import { resolveFullConfig } from '../init-scan-page'
19
+
20
+ const SAMPLE_CONFIG = {
21
+ storage: { kind: 'github' as const },
22
+ products: {
23
+ website: {
24
+ label: 'Website',
25
+ sections: {
26
+ _layout_header: { label: 'header', icon: 'panel-top', fields: { items: { type: 'array' } } },
27
+ _layout_footer: { label: 'footer', icon: 'file-text', fields: { description: { type: 'text' } } },
28
+ },
29
+ },
30
+ },
31
+ }
32
+
33
+ beforeEach(() => {
34
+ delete (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__
35
+ })
36
+
37
+ afterEach(() => {
38
+ delete (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__
39
+ })
40
+
41
+ describe('resolveFullConfig', () => {
42
+ it('returns undefined when neither build-define nor globalThis is set', () => {
43
+ expect(resolveFullConfig()).toBeUndefined()
44
+ })
45
+
46
+ it('reads config from globalThis.__SETZKASTEN_FULL_CONFIG__', () => {
47
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = SAMPLE_CONFIG
48
+ const result = resolveFullConfig()
49
+ expect(result).toBeDefined()
50
+ expect(result?.products.website?.sections._layout_header).toBeDefined()
51
+ expect(result?.products.website?.sections._layout_footer).toBeDefined()
52
+ })
53
+
54
+ it('exposes section keys so managedSections can detect adopted layout regions', () => {
55
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = SAMPLE_CONFIG
56
+ const result = resolveFullConfig()
57
+ const keys = result ? Object.keys(result.products.website!.sections) : []
58
+ expect(keys).toContain('_layout_header')
59
+ expect(keys).toContain('_layout_footer')
60
+ })
61
+ })
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+ import { resolveLicenseTier } from '../_license-tier'
7
+
8
+ beforeEach(() => {
9
+ vi.unstubAllEnvs()
10
+ })
11
+
12
+ afterEach(() => {
13
+ vi.unstubAllEnvs()
14
+ })
15
+
16
+ describe('resolveLicenseTier', () => {
17
+ it('returns free when no license key is configured', () => {
18
+ expect(resolveLicenseTier()).toBe('free')
19
+ })
20
+
21
+ it('returns pro for an SK-PRO-* key', () => {
22
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-PRO-AAAAAAAA-BBBBBBBB-CCCCCCCC')
23
+ expect(resolveLicenseTier()).toBe('pro')
24
+ })
25
+
26
+ it('returns enterprise for an SK-ENT-* key', () => {
27
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-ENT-AAAAAAAA-BBBBBBBB-CCCCCCCC')
28
+ expect(resolveLicenseTier()).toBe('enterprise')
29
+ })
30
+
31
+ it('returns free for an unrecognised prefix', () => {
32
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-FAKE-XXXXXXXX')
33
+ expect(resolveLicenseTier()).toBe('free')
34
+ })
35
+
36
+ it('returns free for empty string', () => {
37
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', '')
38
+ expect(resolveLicenseTier()).toBe('free')
39
+ })
40
+
41
+ it('trims surrounding whitespace before deciding', () => {
42
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', ' SK-PRO-AAAA-BBBB-CCCC ')
43
+ expect(resolveLicenseTier()).toBe('pro')
44
+ })
45
+ })
@@ -0,0 +1,189 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { generateKeyPairSync } from 'node:crypto'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
9
+ const PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
10
+
11
+ function makeCtx(body: unknown, sessionValue = 'valid', role: 'admin' | 'editor' = 'admin') {
12
+ const request = new Request('https://cms.example.com/api/setzkasten/migrate/to-multi', {
13
+ method: 'POST',
14
+ body: JSON.stringify(body),
15
+ headers: { 'content-type': 'application/json' },
16
+ })
17
+ const sessionPayload = sessionValue
18
+ ? JSON.stringify({ user: { email: 'a@b.com', role }, expiresAt: Date.now() + 60_000 })
19
+ : ''
20
+ return {
21
+ request,
22
+ cookies: {
23
+ get: vi.fn((name: string) =>
24
+ name === 'setzkasten_session' && sessionPayload ? { value: sessionPayload } : undefined,
25
+ ),
26
+ },
27
+ }
28
+ }
29
+
30
+ beforeEach(() => {
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
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-PRO-AAAA-BBBB-CCCC')
36
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
37
+ storage: { kind: 'single', repo: 'acme/site', appId: '1', installationId: '111' },
38
+ }
39
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = {
40
+ owner: 'acme',
41
+ repo: 'site',
42
+ branch: 'main',
43
+ contentPath: 'content',
44
+ assetsPath: 'public/images',
45
+ projectPrefix: '',
46
+ }
47
+ })
48
+
49
+ afterEach(() => {
50
+ vi.restoreAllMocks()
51
+ vi.unstubAllEnvs()
52
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
53
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = undefined
54
+ })
55
+
56
+ describe('POST /api/setzkasten/migrate/to-multi', () => {
57
+ it('returns 401 without a session', async () => {
58
+ const { POST } = await import('../migrate-to-multi')
59
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({}, ''))
60
+
61
+ expect(res.status).toBe(401)
62
+ })
63
+
64
+ it('returns 403 for non-admin users', async () => {
65
+ const { POST } = await import('../migrate-to-multi')
66
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({}, 'valid', 'editor'))
67
+
68
+ expect(res.status).toBe(403)
69
+ })
70
+
71
+ it('returns 402 for free license tier', async () => {
72
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', '')
73
+ const { POST } = await import('../migrate-to-multi')
74
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(
75
+ makeCtx({ configRepo: 'acme/cms-config', configInstallationId: '999' }),
76
+ )
77
+ const body = (await res.json()) as { error?: string }
78
+
79
+ expect(res.status).toBe(402)
80
+ expect(body.error).toMatch(/Pro.*Enterprise/i)
81
+ })
82
+
83
+ it('returns 400 when configRepo is missing', async () => {
84
+ const { POST } = await import('../migrate-to-multi')
85
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(
86
+ makeCtx({ configInstallationId: '999' }),
87
+ )
88
+
89
+ expect(res.status).toBe(400)
90
+ })
91
+
92
+ it('returns 400 when configInstallationId is missing', async () => {
93
+ const { POST } = await import('../migrate-to-multi')
94
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(
95
+ makeCtx({ configRepo: 'acme/cms-config' }),
96
+ )
97
+
98
+ expect(res.status).toBe(400)
99
+ })
100
+
101
+ it('returns 400 when current setup is already multi', async () => {
102
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
103
+ storage: { kind: 'multi', configRepo: 'a/b', appId: '1', installationId: '1' },
104
+ }
105
+
106
+ const { POST } = await import('../migrate-to-multi')
107
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(
108
+ makeCtx({ configRepo: 'acme/cms-config', configInstallationId: '999' }),
109
+ )
110
+
111
+ expect(res.status).toBe(400)
112
+ })
113
+
114
+ it('copies editors+global and writes a single-entry websites.json', async () => {
115
+ const calls: Array<{ url: string; method?: string; body?: unknown }> = []
116
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
117
+ calls.push({ url, method: init?.method, body: init?.body })
118
+ if (url.includes('/access_tokens')) {
119
+ return {
120
+ ok: true,
121
+ json: async () => ({
122
+ token: 'gh_mock',
123
+ expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
124
+ }),
125
+ } as Response
126
+ }
127
+ // Source reads (website-repo)
128
+ if (url.includes('/repos/acme/site/contents/content/_editors.json')) {
129
+ return {
130
+ ok: true,
131
+ json: async () => ({
132
+ content: Buffer.from(JSON.stringify([{ email: 'a@b.com' }])).toString('base64'),
133
+ sha: 'editors-sha',
134
+ }),
135
+ } as Response
136
+ }
137
+ if (url.includes('/repos/acme/site/contents/content/_global_config.json')) {
138
+ return {
139
+ ok: true,
140
+ json: async () => ({
141
+ content: Buffer.from(JSON.stringify({ theme: { brandName: 'X' } })).toString('base64'),
142
+ sha: 'gc-sha',
143
+ }),
144
+ } as Response
145
+ }
146
+ // Target writes (config-repo)
147
+ if (url.includes('/repos/acme/cms-config/contents/') && init?.method === 'PUT') {
148
+ return { ok: true, json: async () => ({ content: { sha: 'new' } }) } as Response
149
+ }
150
+ // Target reads (websites.json) — assume not yet present
151
+ if (
152
+ url.includes('/repos/acme/cms-config/contents/websites.json') &&
153
+ (init?.method ?? 'GET') === 'GET'
154
+ ) {
155
+ return new Response(null, { status: 404 })
156
+ }
157
+ throw new Error(`unexpected URL: ${url} method=${init?.method}`)
158
+ })
159
+ vi.stubGlobal('fetch', fetchMock)
160
+
161
+ const { POST } = await import('../migrate-to-multi')
162
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(
163
+ makeCtx({ configRepo: 'acme/cms-config', configInstallationId: '999' }),
164
+ )
165
+
166
+ expect(res.status).toBe(200)
167
+ const body = (await res.json()) as {
168
+ ok: boolean
169
+ committed: { editors: boolean; globalConfig: boolean; websites: boolean }
170
+ }
171
+ expect(body.ok).toBe(true)
172
+ expect(body.committed.editors).toBe(true)
173
+ expect(body.committed.globalConfig).toBe(true)
174
+ expect(body.committed.websites).toBe(true)
175
+
176
+ // Ensure the websites.json write contained the source-website snapshot
177
+ const websitesPut = calls.find(
178
+ (c) => c.method === 'PUT' && c.url.includes('/repos/acme/cms-config/contents/websites.json'),
179
+ )
180
+ expect(websitesPut).toBeDefined()
181
+ const websitesBody = JSON.parse(String(websitesPut!.body)) as { content: string }
182
+ const websitesPayload = JSON.parse(
183
+ Buffer.from(websitesBody.content, 'base64').toString('utf-8'),
184
+ )
185
+ expect(websitesPayload.websites).toHaveLength(1)
186
+ expect(websitesPayload.websites[0].repo).toBe('acme/site')
187
+ expect(websitesPayload.websites[0].githubApp.installationId).toBe('111')
188
+ })
189
+ })