@setzkasten-cms/astro-admin 0.6.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 (79) hide show
  1. package/package.json +23 -6
  2. package/src/admin-page.astro +9 -8
  3. package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
  4. package/src/api-routes/__tests__/commit-trailers.test.ts +69 -0
  5. package/src/api-routes/__tests__/github-cache.test.ts +100 -0
  6. package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
  7. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  8. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  9. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  10. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  11. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  12. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
  13. package/src/api-routes/__tests__/pages.test.ts +72 -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 +145 -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__/website-resolver-bootstrap-standalone.test.ts +85 -0
  21. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  22. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  23. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  24. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  25. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  26. package/src/api-routes/_auth-guard.ts +153 -0
  27. package/src/api-routes/_commit-trailers.ts +16 -0
  28. package/src/api-routes/_github-cache.ts +32 -0
  29. package/src/api-routes/_github-token.ts +64 -0
  30. package/src/api-routes/_license-tier.ts +25 -0
  31. package/src/api-routes/_pages-meta-store.ts +134 -0
  32. package/src/api-routes/_session-cookie.ts +42 -0
  33. package/src/api-routes/_storage-config.ts +64 -4
  34. package/src/api-routes/_vercel-origin.ts +22 -0
  35. package/src/api-routes/_website-resolver.ts +243 -0
  36. package/src/api-routes/_websites-store.ts +120 -0
  37. package/src/api-routes/asset-proxy.ts +6 -4
  38. package/src/api-routes/auth-callback.ts +21 -53
  39. package/src/api-routes/auth-login.ts +18 -65
  40. package/src/api-routes/auth-logout.ts +5 -1
  41. package/src/api-routes/auth-setzkasten-login.ts +71 -0
  42. package/src/api-routes/catalog-add.ts +18 -5
  43. package/src/api-routes/catalog-export.ts +8 -4
  44. package/src/api-routes/config.ts +17 -5
  45. package/src/api-routes/editors.ts +205 -0
  46. package/src/api-routes/github-proxy.ts +5 -5
  47. package/src/api-routes/global-config.ts +149 -0
  48. package/src/api-routes/init-add-section.ts +21 -10
  49. package/src/api-routes/init-apply.ts +7 -4
  50. package/src/api-routes/init-migrate.ts +9 -6
  51. package/src/api-routes/init-scan-page.ts +26 -6
  52. package/src/api-routes/init-scan.ts +5 -3
  53. package/src/api-routes/migrate-to-multi.ts +255 -0
  54. package/src/api-routes/pages.ts +138 -6
  55. package/src/api-routes/section-add.ts +23 -5
  56. package/src/api-routes/section-commit-pending.ts +28 -5
  57. package/src/api-routes/section-delete.ts +24 -5
  58. package/src/api-routes/section-duplicate.ts +25 -5
  59. package/src/api-routes/section-prepare-copy.ts +15 -4
  60. package/src/api-routes/section-prepare.ts +12 -4
  61. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  62. package/src/api-routes/setup-github-app-branches.ts +63 -0
  63. package/src/api-routes/setup-github-app-callback.ts +53 -0
  64. package/src/api-routes/setup-github-app-installed.ts +44 -0
  65. package/src/api-routes/setup-github-app-repos.ts +46 -0
  66. package/src/api-routes/setup-github-app.ts +58 -0
  67. package/src/api-routes/updater-check.ts +49 -0
  68. package/src/api-routes/updater-register.ts +90 -0
  69. package/src/api-routes/updater-transfer.ts +51 -0
  70. package/src/api-routes/updater-unbind.ts +59 -0
  71. package/src/api-routes/websites-add.ts +113 -0
  72. package/src/api-routes/websites-list.ts +40 -0
  73. package/src/api-routes/websites-remove.ts +74 -0
  74. package/src/init/__tests__/page-level.test.ts +47 -0
  75. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  76. package/src/init/__tests__/section-pipeline.test.ts +3 -1
  77. package/src/init/astro-section-analyzer-v2.ts +29 -2
  78. package/src/init/template-patcher-v2.ts +100 -0
  79. package/LICENSE +0 -37
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+ import {
7
+ __resetWebsiteResolverForTests,
8
+ bootstrapResolverFromGlobals,
9
+ resolveCurrentWebsite,
10
+ } from '../_website-resolver'
11
+
12
+ const SINGLE_FULL_CONFIG = {
13
+ storage: { kind: 'github-app', repo: 'acme/site', appId: '11', installationId: '99' },
14
+ }
15
+
16
+ const SINGLE_STORAGE = {
17
+ owner: 'acme',
18
+ repo: 'site',
19
+ branch: 'main',
20
+ contentPath: 'content',
21
+ assetsPath: 'public/images',
22
+ projectPrefix: '',
23
+ }
24
+
25
+ const STANDALONE_FULL_CONFIG = {
26
+ storage: {
27
+ kind: 'standalone',
28
+ configRepo: 'acme/cms-config',
29
+ configBranch: 'main',
30
+ appId: '11',
31
+ installationId: '777',
32
+ },
33
+ }
34
+
35
+ beforeEach(() => {
36
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
37
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = undefined
38
+ vi.unstubAllEnvs()
39
+ })
40
+
41
+ afterEach(() => {
42
+ __resetWebsiteResolverForTests(null)
43
+ vi.unstubAllEnvs()
44
+ })
45
+
46
+ describe('bootstrapResolverFromGlobals – single-repo mode', () => {
47
+ it('synthesizes a WebsiteEntry from build-time storage + full-config + ENV', async () => {
48
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = SINGLE_FULL_CONFIG
49
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = SINGLE_STORAGE
50
+ vi.stubEnv('GITHUB_APP_ID', '11')
51
+ vi.stubEnv('GITHUB_APP_INSTALLATION_ID', '99')
52
+ vi.stubEnv('PUBLIC_SITE_URL', 'https://acme-site.example.com')
53
+
54
+ bootstrapResolverFromGlobals()
55
+ const req = new Request('https://cms.example.com/anything')
56
+ const result = await resolveCurrentWebsite(req)
57
+
58
+ expect(result.ok).toBe(true)
59
+ if (result.ok) {
60
+ expect(result.value.repo).toBe('acme/site')
61
+ expect(result.value.branch).toBe('main')
62
+ expect(result.value.githubApp.installationId).toBe('99')
63
+ }
64
+ })
65
+
66
+ it('falls back to localhost previewOrigin when PUBLIC_SITE_URL is not set', async () => {
67
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = SINGLE_FULL_CONFIG
68
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = SINGLE_STORAGE
69
+ vi.stubEnv('GITHUB_APP_ID', '11')
70
+ vi.stubEnv('GITHUB_APP_INSTALLATION_ID', '99')
71
+
72
+ bootstrapResolverFromGlobals()
73
+ const result = await resolveCurrentWebsite(new Request('https://cms.example.com/anything'))
74
+
75
+ expect(result.ok).toBe(true)
76
+ if (result.ok) expect(result.value.previewOrigin).toMatch(/localhost/)
77
+ })
78
+ })
79
+
80
+ describe('bootstrapResolverFromGlobals – standalone mode', () => {
81
+ it('does not crash when storage.kind === "standalone"', () => {
82
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = STANDALONE_FULL_CONFIG
83
+
84
+ expect(() => bootstrapResolverFromGlobals()).not.toThrow()
85
+ })
86
+ })
87
+
88
+ describe('bootstrapResolverFromGlobals – idempotent', () => {
89
+ it('does not overwrite existing state on a second call', async () => {
90
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = SINGLE_FULL_CONFIG
91
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = SINGLE_STORAGE
92
+ vi.stubEnv('GITHUB_APP_ID', '11')
93
+ vi.stubEnv('GITHUB_APP_INSTALLATION_ID', '99')
94
+
95
+ bootstrapResolverFromGlobals()
96
+ const first = await resolveCurrentWebsite(new Request('https://x'))
97
+
98
+ // change globals + bootstrap again — must be a no-op
99
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = {
100
+ ...SINGLE_STORAGE,
101
+ repo: 'changed',
102
+ }
103
+ bootstrapResolverFromGlobals()
104
+
105
+ const second = await resolveCurrentWebsite(new Request('https://x'))
106
+ if (first.ok && second.ok) expect(second.value.repo).toBe(first.value.repo)
107
+ })
108
+ })
@@ -0,0 +1,123 @@
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 { __resetWebsiteResolverForTests, resolveCurrentWebsite } from '../_website-resolver'
8
+
9
+ const ENTRY_A: WebsiteEntry = {
10
+ id: 'site-a',
11
+ name: 'Site A',
12
+ repo: 'acme/site-a',
13
+ branch: 'main',
14
+ previewOrigin: 'https://a.example.com',
15
+ githubApp: { appId: '1', installationId: '101' },
16
+ }
17
+
18
+ const ENTRY_B: WebsiteEntry = {
19
+ id: 'site-b',
20
+ name: 'Site B',
21
+ repo: 'acme/site-b',
22
+ branch: 'develop',
23
+ previewOrigin: 'https://b.example.com',
24
+ githubApp: { appId: '1', installationId: '202' },
25
+ }
26
+
27
+ interface MockRegistry {
28
+ list: ReturnType<typeof vi.fn>
29
+ get: ReturnType<typeof vi.fn>
30
+ }
31
+
32
+ function makeRegistry(entries: readonly WebsiteEntry[]): MockRegistry {
33
+ return {
34
+ list: vi.fn(async (): Promise<Result<readonly WebsiteEntry[]>> => ok(entries)),
35
+ get: vi.fn(async (id: string): Promise<Result<WebsiteEntry | null>> => {
36
+ return ok(entries.find((e) => e.id === id) ?? null)
37
+ }),
38
+ }
39
+ }
40
+
41
+ function reqWithHeader(value: string | null) {
42
+ const headers: Record<string, string> = {}
43
+ if (value !== null) headers['x-sk-website'] = value
44
+ return new Request('https://cms.example.com/api/anything', { headers })
45
+ }
46
+
47
+ describe('resolveCurrentWebsite – standalone mode', () => {
48
+ beforeEach(() => {
49
+ __resetWebsiteResolverForTests({
50
+ mode: 'multi',
51
+ registry: makeRegistry([ENTRY_A, ENTRY_B]),
52
+ })
53
+ })
54
+
55
+ afterEach(() => __resetWebsiteResolverForTests(null))
56
+
57
+ it('returns the entry matching X-SK-Website', async () => {
58
+ const result = await resolveCurrentWebsite(reqWithHeader('site-b'))
59
+
60
+ expect(result.ok).toBe(true)
61
+ if (result.ok) expect(result.value.id).toBe('site-b')
62
+ })
63
+
64
+ it('returns 400-style validation error when header is missing and registry has multiple entries', async () => {
65
+ const result = await resolveCurrentWebsite(reqWithHeader(null))
66
+
67
+ expect(result.ok).toBe(false)
68
+ if (!result.ok) {
69
+ expect(result.error.type).toBe('validation')
70
+ expect(result.error.message).toMatch(/X-SK-Website/i)
71
+ }
72
+ })
73
+
74
+ it('auto-selects the only entry when the registry has exactly one website', async () => {
75
+ __resetWebsiteResolverForTests({
76
+ mode: 'multi',
77
+ registry: makeRegistry([ENTRY_A]),
78
+ })
79
+
80
+ const result = await resolveCurrentWebsite(reqWithHeader(null))
81
+
82
+ expect(result.ok).toBe(true)
83
+ if (result.ok) expect(result.value.id).toBe('site-a')
84
+ })
85
+
86
+ it('returns not-found error for unknown website id', async () => {
87
+ const result = await resolveCurrentWebsite(reqWithHeader('does-not-exist'))
88
+
89
+ expect(result.ok).toBe(false)
90
+ if (!result.ok) expect(result.error.type).toBe('not-found')
91
+ })
92
+
93
+ it('rejects empty header values', async () => {
94
+ const result = await resolveCurrentWebsite(reqWithHeader(''))
95
+
96
+ expect(result.ok).toBe(false)
97
+ })
98
+ })
99
+
100
+ describe('resolveCurrentWebsite – single-repo mode (backward compat)', () => {
101
+ beforeEach(() => {
102
+ __resetWebsiteResolverForTests({
103
+ mode: 'single',
104
+ synthesized: ENTRY_A,
105
+ })
106
+ })
107
+
108
+ afterEach(() => __resetWebsiteResolverForTests(null))
109
+
110
+ it('always returns the synthesized entry — header is ignored', async () => {
111
+ const result = await resolveCurrentWebsite(reqWithHeader('whatever'))
112
+
113
+ expect(result.ok).toBe(true)
114
+ if (result.ok) expect(result.value.id).toBe(ENTRY_A.id)
115
+ })
116
+
117
+ it('still works when no header is sent', async () => {
118
+ const result = await resolveCurrentWebsite(reqWithHeader(null))
119
+
120
+ expect(result.ok).toBe(true)
121
+ if (result.ok) expect(result.value.repo).toBe(ENTRY_A.repo)
122
+ })
123
+ })
@@ -0,0 +1,305 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { generateKeyPairSync } from 'node:crypto'
6
+ import type { WebsiteEntry } from '@setzkasten-cms/core'
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+
9
+ const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
10
+ const PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
11
+
12
+ const VALID_ENTRY: WebsiteEntry = {
13
+ id: 'new-site',
14
+ name: 'New Site',
15
+ repo: 'acme/new-site',
16
+ branch: 'main',
17
+ previewOrigin: 'https://new.example.com',
18
+ githubApp: { appId: '1', installationId: '999' },
19
+ }
20
+
21
+ const EXISTING_REGISTRY = {
22
+ websites: [
23
+ {
24
+ id: 'existing',
25
+ name: 'Existing',
26
+ repo: 'acme/existing',
27
+ branch: 'main',
28
+ previewOrigin: 'https://existing.example.com',
29
+ githubApp: { appId: '1', installationId: '111' },
30
+ },
31
+ ],
32
+ }
33
+
34
+ // Admin sessions only — websites-add is admin-gated. Tests that exercise
35
+ // the unauthorized branch pass `null` to drop the cookie entirely.
36
+ const ADMIN_SESSION = JSON.stringify({
37
+ user: {
38
+ id: 'u1',
39
+ email: 'admin@example.com',
40
+ role: 'admin',
41
+ provider: 'github',
42
+ },
43
+ expiresAt: Date.now() + 60 * 60 * 1000,
44
+ })
45
+
46
+ function makeCtx(body: unknown, sessionValue: string | null = ADMIN_SESSION) {
47
+ const request = new Request('https://cms.example.com/api/setzkasten/websites/add', {
48
+ method: 'POST',
49
+ body: JSON.stringify(body),
50
+ headers: { 'content-type': 'application/json' },
51
+ })
52
+ return {
53
+ request,
54
+ cookies: {
55
+ get: vi.fn((name: string) =>
56
+ name === 'setzkasten_session' && sessionValue ? { value: sessionValue } : undefined,
57
+ ),
58
+ },
59
+ }
60
+ }
61
+
62
+ beforeEach(() => {
63
+ vi.unstubAllEnvs()
64
+ vi.stubEnv('GITHUB_APP_ID', '1')
65
+ vi.stubEnv('GITHUB_APP_INSTALLATION_ID', '111')
66
+ vi.stubEnv('GITHUB_APP_PRIVATE_KEY', PEM)
67
+ // Default: pro license — enough for the existing happy-path assertions.
68
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-PRO-AAAAAAAA-BBBBBBBB-CCCCCCCC')
69
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
70
+ storage: {
71
+ kind: 'standalone',
72
+ configRepo: 'acme/cms-config',
73
+ configBranch: 'main',
74
+ appId: '1',
75
+ installationId: '111',
76
+ },
77
+ }
78
+ })
79
+
80
+ afterEach(() => {
81
+ vi.restoreAllMocks()
82
+ vi.unstubAllEnvs()
83
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
84
+ })
85
+
86
+ describe('POST /api/setzkasten/websites/add', () => {
87
+ it('returns 401 without a session', async () => {
88
+ const { POST } = await import('../websites-add')
89
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(
90
+ makeCtx({ entry: VALID_ENTRY }, null),
91
+ )
92
+
93
+ expect(res.status).toBe(401)
94
+ })
95
+
96
+ it('returns 400 when the body is missing entry', async () => {
97
+ const { POST } = await import('../websites-add')
98
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({}))
99
+
100
+ expect(res.status).toBe(400)
101
+ })
102
+
103
+ it('returns 400 when entry is malformed (slug check)', async () => {
104
+ const { POST } = await import('../websites-add')
105
+ const broken = { ...VALID_ENTRY, id: 'has spaces' }
106
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({ entry: broken }))
107
+
108
+ expect(res.status).toBe(400)
109
+ })
110
+
111
+ it('returns 409 when the id already exists in the registry', async () => {
112
+ const fetchMock = vi.fn(async (url: string) => {
113
+ if (url.includes('/contents/websites.json')) {
114
+ return {
115
+ ok: true,
116
+ status: 200,
117
+ json: async () => ({
118
+ content: Buffer.from(JSON.stringify(EXISTING_REGISTRY)).toString('base64'),
119
+ sha: 'abc',
120
+ }),
121
+ } as Response
122
+ }
123
+ if (url.includes('/access_tokens')) {
124
+ return {
125
+ ok: true,
126
+ json: async () => ({
127
+ token: 'gh_mock',
128
+ expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
129
+ }),
130
+ } as Response
131
+ }
132
+ throw new Error(`unexpected URL: ${url}`)
133
+ })
134
+ vi.stubGlobal('fetch', fetchMock)
135
+
136
+ const dup = { ...VALID_ENTRY, id: 'existing' }
137
+ const { POST } = await import('../websites-add')
138
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({ entry: dup }))
139
+
140
+ expect(res.status).toBe(409)
141
+ })
142
+
143
+ it('writes an updated registry to GitHub on success', async () => {
144
+ const calls: Array<{ url: string; method?: string; body?: unknown }> = []
145
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
146
+ calls.push({ url, method: init?.method, body: init?.body })
147
+ if (url.includes('/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
+ if (url.includes('/contents/websites.json') && (init?.method ?? 'GET') === 'GET') {
157
+ return {
158
+ ok: true,
159
+ status: 200,
160
+ json: async () => ({
161
+ content: Buffer.from(JSON.stringify(EXISTING_REGISTRY)).toString('base64'),
162
+ sha: 'abc',
163
+ }),
164
+ } as Response
165
+ }
166
+ if (url.includes('/contents/websites.json') && init?.method === 'PUT') {
167
+ return {
168
+ ok: true,
169
+ status: 200,
170
+ json: async () => ({ content: { sha: 'new-sha' } }),
171
+ } as Response
172
+ }
173
+ throw new Error(`unexpected URL: ${url} method=${init?.method}`)
174
+ })
175
+ vi.stubGlobal('fetch', fetchMock)
176
+
177
+ const { POST } = await import('../websites-add')
178
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({ entry: VALID_ENTRY }))
179
+
180
+ expect(res.status).toBe(200)
181
+
182
+ const putCall = calls.find((c) => c.method === 'PUT')
183
+ expect(putCall).toBeDefined()
184
+ const writtenBody = JSON.parse(String(putCall!.body)) as {
185
+ content: string
186
+ sha?: string
187
+ branch: string
188
+ }
189
+ expect(writtenBody.sha).toBe('abc')
190
+ const decoded = JSON.parse(Buffer.from(writtenBody.content, 'base64').toString('utf-8'))
191
+ expect(decoded.websites.map((w: WebsiteEntry) => w.id)).toEqual(['existing', 'new-site'])
192
+ })
193
+ })
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // License gating
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function mockGithubFetchWithRegistry(websites: unknown[]) {
200
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
201
+ if (url.includes('/access_tokens')) {
202
+ return {
203
+ ok: true,
204
+ json: async () => ({
205
+ token: 'gh_mock',
206
+ expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
207
+ }),
208
+ } as Response
209
+ }
210
+ if (url.includes('/contents/websites.json') && (init?.method ?? 'GET') === 'GET') {
211
+ return {
212
+ ok: true,
213
+ json: async () => ({
214
+ content: Buffer.from(JSON.stringify({ websites })).toString('base64'),
215
+ sha: 'abc',
216
+ }),
217
+ } as Response
218
+ }
219
+ if (url.includes('/contents/websites.json') && init?.method === 'PUT') {
220
+ return { ok: true, json: async () => ({ content: { sha: 'new' } }) } as Response
221
+ }
222
+ throw new Error(`unexpected URL: ${url} method=${init?.method}`)
223
+ })
224
+ vi.stubGlobal('fetch', fetchMock)
225
+ }
226
+
227
+ function makeEntry(id: string): WebsiteEntry {
228
+ return {
229
+ id,
230
+ name: id,
231
+ repo: `acme/${id}`,
232
+ branch: 'main',
233
+ previewOrigin: `https://${id}.example.com`,
234
+ githubApp: { appId: '1', installationId: id.replace(/\D/g, '') || '111' },
235
+ }
236
+ }
237
+
238
+ describe('POST /api/setzkasten/websites/add — license gating', () => {
239
+ it('returns 402 with a clear message when free license is used', async () => {
240
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', '')
241
+ mockGithubFetchWithRegistry([])
242
+
243
+ const { POST } = await import('../websites-add')
244
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({ entry: VALID_ENTRY }))
245
+ const body = (await res.json()) as { error?: string }
246
+
247
+ expect(res.status).toBe(402)
248
+ expect(body.error).toMatch(/Pro.*Enterprise/i)
249
+ })
250
+
251
+ it('returns 402 when pro tier is at the 5-website limit', async () => {
252
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-PRO-AAAA-BBBB-CCCC')
253
+ mockGithubFetchWithRegistry([
254
+ makeEntry('site-1'),
255
+ makeEntry('site-2'),
256
+ makeEntry('site-3'),
257
+ makeEntry('site-4'),
258
+ makeEntry('site-5'),
259
+ ])
260
+
261
+ const { POST } = await import('../websites-add')
262
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({ entry: VALID_ENTRY }))
263
+ const body = (await res.json()) as { error?: string }
264
+
265
+ expect(res.status).toBe(402)
266
+ expect(body.error).toMatch(/Pro.*5/i)
267
+ })
268
+
269
+ it('accepts the 5th website on pro tier', async () => {
270
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-PRO-AAAA-BBBB-CCCC')
271
+ mockGithubFetchWithRegistry([
272
+ makeEntry('site-1'),
273
+ makeEntry('site-2'),
274
+ makeEntry('site-3'),
275
+ makeEntry('site-4'),
276
+ ])
277
+
278
+ const { POST } = await import('../websites-add')
279
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({ entry: VALID_ENTRY }))
280
+
281
+ expect(res.status).toBe(200)
282
+ })
283
+
284
+ it('accepts up to 20 websites on enterprise tier', async () => {
285
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-ENT-AAAA-BBBB-CCCC')
286
+ mockGithubFetchWithRegistry(Array.from({ length: 19 }, (_, i) => makeEntry(`site-${i + 1}`)))
287
+
288
+ const { POST } = await import('../websites-add')
289
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({ entry: VALID_ENTRY }))
290
+
291
+ expect(res.status).toBe(200)
292
+ })
293
+
294
+ it('returns 402 when enterprise tier is at the 20-website limit', async () => {
295
+ vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-ENT-AAAA-BBBB-CCCC')
296
+ mockGithubFetchWithRegistry(Array.from({ length: 20 }, (_, i) => makeEntry(`site-${i + 1}`)))
297
+
298
+ const { POST } = await import('../websites-add')
299
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({ entry: VALID_ENTRY }))
300
+ const body = (await res.json()) as { error?: string }
301
+
302
+ expect(res.status).toBe(402)
303
+ expect(body.error).toMatch(/Enterprise.*20/i)
304
+ })
305
+ })
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { type Result, type WebsiteEntry, ok } from '@setzkasten-cms/core'
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+ import { __resetWebsiteResolverForTests } from '../_website-resolver'
8
+
9
+ const ENTRY_A: WebsiteEntry = {
10
+ id: 'site-a',
11
+ name: 'Site A',
12
+ repo: 'acme/site-a',
13
+ branch: 'main',
14
+ previewOrigin: 'https://a.example.com',
15
+ githubApp: { appId: '1', installationId: '101' },
16
+ }
17
+
18
+ const ENTRY_B: WebsiteEntry = {
19
+ id: 'site-b',
20
+ name: 'Site B',
21
+ repo: 'acme/site-b',
22
+ branch: 'develop',
23
+ previewOrigin: 'https://b.example.com',
24
+ githubApp: { appId: '1', installationId: '202' },
25
+ allowedEmails: ['secret@example.com'],
26
+ }
27
+
28
+ function makeRegistry(entries: readonly WebsiteEntry[]) {
29
+ return {
30
+ list: vi.fn(async (): Promise<Result<readonly WebsiteEntry[]>> => ok(entries)),
31
+ get: vi.fn(async (id: string): Promise<Result<WebsiteEntry | null>> => {
32
+ return ok(entries.find((e) => e.id === id) ?? null)
33
+ }),
34
+ }
35
+ }
36
+
37
+ function makeCtx(sessionValue?: string) {
38
+ return {
39
+ request: new Request('https://cms.example.com/api/setzkasten/websites'),
40
+ cookies: {
41
+ get: vi.fn((name: string) =>
42
+ name === 'setzkasten_session' && sessionValue ? { value: sessionValue } : undefined,
43
+ ),
44
+ },
45
+ }
46
+ }
47
+
48
+ import { GET } from '../websites-list'
49
+
50
+ describe('GET /api/setzkasten/websites', () => {
51
+ afterEach(() => __resetWebsiteResolverForTests(null))
52
+
53
+ it('returns 401 when no session cookie is present', async () => {
54
+ __resetWebsiteResolverForTests({ mode: 'multi', registry: makeRegistry([ENTRY_A]) })
55
+
56
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx())
57
+
58
+ expect(res.status).toBe(401)
59
+ })
60
+
61
+ it('returns the list of websites when authenticated', async () => {
62
+ __resetWebsiteResolverForTests({
63
+ mode: 'multi',
64
+ registry: makeRegistry([ENTRY_A, ENTRY_B]),
65
+ })
66
+
67
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx('valid'))
68
+ const body = await res.json()
69
+
70
+ expect(res.status).toBe(200)
71
+ expect(body.websites).toHaveLength(2)
72
+ expect(body.websites[0].id).toBe('site-a')
73
+ expect(body.websites[1].id).toBe('site-b')
74
+ })
75
+
76
+ it('does not expose githubApp installation ids in the response', async () => {
77
+ __resetWebsiteResolverForTests({
78
+ mode: 'multi',
79
+ registry: makeRegistry([ENTRY_B]),
80
+ })
81
+
82
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx('valid'))
83
+ const body = await res.json()
84
+
85
+ expect(body.websites[0]).not.toHaveProperty('githubApp')
86
+ expect(JSON.stringify(body)).not.toContain('202')
87
+ })
88
+
89
+ it('does not expose allowedEmails in the response', async () => {
90
+ __resetWebsiteResolverForTests({
91
+ mode: 'multi',
92
+ registry: makeRegistry([ENTRY_B]),
93
+ })
94
+
95
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx('valid'))
96
+ const body = await res.json()
97
+
98
+ expect(body.websites[0]).not.toHaveProperty('allowedEmails')
99
+ expect(JSON.stringify(body)).not.toContain('secret@example.com')
100
+ })
101
+
102
+ it('returns the synthesized entry in single-repo mode', async () => {
103
+ __resetWebsiteResolverForTests({ mode: 'single', synthesized: ENTRY_A })
104
+
105
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx('valid'))
106
+ const body = await res.json()
107
+
108
+ expect(res.status).toBe(200)
109
+ expect(body.websites).toHaveLength(1)
110
+ expect(body.websites[0].id).toBe(ENTRY_A.id)
111
+ })
112
+ })