@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.
- package/package.json +23 -6
- package/src/admin-page.astro +9 -8
- package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
- package/src/api-routes/__tests__/commit-trailers.test.ts +69 -0
- package/src/api-routes/__tests__/github-cache.test.ts +100 -0
- package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
- package/src/api-routes/__tests__/github-token.test.ts +78 -0
- package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
- package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
- package/src/api-routes/__tests__/license-tier.test.ts +45 -0
- package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
- package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
- package/src/api-routes/__tests__/pages.test.ts +72 -0
- package/src/api-routes/__tests__/route-registry.test.ts +120 -0
- package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
- package/src/api-routes/__tests__/setup-github-app-callback.test.ts +145 -0
- package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
- package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
- package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
- package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
- package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
- package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
- package/src/api-routes/__tests__/websites-add.test.ts +305 -0
- package/src/api-routes/__tests__/websites-list.test.ts +112 -0
- package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
- package/src/api-routes/_auth-guard.ts +153 -0
- package/src/api-routes/_commit-trailers.ts +16 -0
- package/src/api-routes/_github-cache.ts +32 -0
- package/src/api-routes/_github-token.ts +64 -0
- package/src/api-routes/_license-tier.ts +25 -0
- package/src/api-routes/_pages-meta-store.ts +134 -0
- package/src/api-routes/_session-cookie.ts +42 -0
- package/src/api-routes/_storage-config.ts +64 -4
- package/src/api-routes/_vercel-origin.ts +22 -0
- package/src/api-routes/_website-resolver.ts +243 -0
- package/src/api-routes/_websites-store.ts +120 -0
- package/src/api-routes/asset-proxy.ts +6 -4
- package/src/api-routes/auth-callback.ts +21 -53
- package/src/api-routes/auth-login.ts +18 -65
- package/src/api-routes/auth-logout.ts +5 -1
- package/src/api-routes/auth-setzkasten-login.ts +71 -0
- package/src/api-routes/catalog-add.ts +18 -5
- package/src/api-routes/catalog-export.ts +8 -4
- package/src/api-routes/config.ts +17 -5
- package/src/api-routes/editors.ts +205 -0
- package/src/api-routes/github-proxy.ts +5 -5
- package/src/api-routes/global-config.ts +149 -0
- package/src/api-routes/init-add-section.ts +21 -10
- package/src/api-routes/init-apply.ts +7 -4
- package/src/api-routes/init-migrate.ts +9 -6
- package/src/api-routes/init-scan-page.ts +26 -6
- package/src/api-routes/init-scan.ts +5 -3
- package/src/api-routes/migrate-to-multi.ts +255 -0
- package/src/api-routes/pages.ts +138 -6
- package/src/api-routes/section-add.ts +23 -5
- package/src/api-routes/section-commit-pending.ts +28 -5
- package/src/api-routes/section-delete.ts +24 -5
- package/src/api-routes/section-duplicate.ts +25 -5
- package/src/api-routes/section-prepare-copy.ts +15 -4
- package/src/api-routes/section-prepare.ts +12 -4
- package/src/api-routes/setup-github-app-bounce.ts +52 -0
- package/src/api-routes/setup-github-app-branches.ts +63 -0
- package/src/api-routes/setup-github-app-callback.ts +53 -0
- package/src/api-routes/setup-github-app-installed.ts +44 -0
- package/src/api-routes/setup-github-app-repos.ts +46 -0
- package/src/api-routes/setup-github-app.ts +58 -0
- package/src/api-routes/updater-check.ts +49 -0
- package/src/api-routes/updater-register.ts +90 -0
- package/src/api-routes/updater-transfer.ts +51 -0
- package/src/api-routes/updater-unbind.ts +59 -0
- package/src/api-routes/websites-add.ts +113 -0
- package/src/api-routes/websites-list.ts +40 -0
- package/src/api-routes/websites-remove.ts +74 -0
- package/src/init/__tests__/page-level.test.ts +47 -0
- package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
- package/src/init/__tests__/section-pipeline.test.ts +3 -1
- package/src/init/astro-section-analyzer-v2.ts +29 -2
- package/src/init/template-patcher-v2.ts +100 -0
- 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
|
+
})
|