@setzkasten-cms/astro-admin 0.8.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 +16 -6
- package/src/admin-page.astro +1 -1
- package/src/api-routes/__tests__/auth-guard.test.ts +134 -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__/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 +134 -13
- 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 +6 -7
- package/src/api-routes/auth-logout.ts +5 -1
- package/src/api-routes/auth-setzkasten-login.ts +21 -10
- package/src/api-routes/catalog-add.ts +9 -5
- package/src/api-routes/catalog-export.ts +8 -4
- package/src/api-routes/config.ts +12 -5
- package/src/api-routes/editors.ts +79 -10
- package/src/api-routes/github-proxy.ts +5 -5
- package/src/api-routes/global-config.ts +23 -6
- package/src/api-routes/init-add-section.ts +13 -5
- package/src/api-routes/init-apply.ts +5 -3
- package/src/api-routes/init-migrate.ts +7 -5
- 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 +118 -4
- package/src/api-routes/section-add.ts +15 -5
- package/src/api-routes/section-commit-pending.ts +18 -5
- package/src/api-routes/section-delete.ts +15 -5
- package/src/api-routes/section-duplicate.ts +15 -5
- package/src/api-routes/section-prepare-copy.ts +15 -4
- package/src/api-routes/section-prepare.ts +9 -5
- 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-register.ts +6 -23
- package/src/api-routes/updater-transfer.ts +1 -12
- 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__/patcher-mixed-content-wrapper.test.ts +90 -0
- package/src/init/template-patcher-v2.ts +33 -0
- package/LICENSE +0 -37
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for setup-github-app-callback.ts
|
|
3
|
+
*
|
|
4
|
+
* Key regression: cookies.set() nach Response.redirect() wirft
|
|
5
|
+
* "TypeError: immutable" weil Response.redirect() eine frozen Response
|
|
6
|
+
* zurückgibt. Fix: manuelles new Response(null, { status: 302 }).
|
|
7
|
+
*
|
|
8
|
+
* @vitest-environment node
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Minimal Astro context mock
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
function makeCookies() {
|
|
18
|
+
const jar: Record<string, string> = {}
|
|
19
|
+
return {
|
|
20
|
+
set: vi.fn((name: string, value: string) => { jar[name] = value }),
|
|
21
|
+
get: vi.fn((name: string) => jar[name] ? { value: jar[name] } : undefined),
|
|
22
|
+
_jar: jar,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeCtx(code: string | null, headers: Record<string, string> = {}) {
|
|
27
|
+
const search = code ? `?code=${code}` : ''
|
|
28
|
+
const url = new URL(`https://localhost/api/setzkasten/setup/github-app/callback${search}`)
|
|
29
|
+
const request = new Request(url, { headers: { 'x-forwarded-host': 'setzkasten.vercel.app', 'x-forwarded-proto': 'https', ...headers } })
|
|
30
|
+
return { url, request, cookies: makeCookies() }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const GITHUB_RESPONSE = {
|
|
34
|
+
id: 123456,
|
|
35
|
+
slug: 'meine-cms-app',
|
|
36
|
+
pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----\n',
|
|
37
|
+
client_id: 'Iv1.abc',
|
|
38
|
+
client_secret: 'secret',
|
|
39
|
+
webhook_secret: null,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Tests
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
describe('setup-github-app-callback', () => {
|
|
47
|
+
beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
|
|
48
|
+
afterEach(() => { vi.restoreAllMocks(); vi.resetModules() })
|
|
49
|
+
|
|
50
|
+
it('setzt Cookie und leitet zum Standard-adminPath weiter bei erfolgreichem Code-Austausch', async () => {
|
|
51
|
+
vi.mocked(fetch).mockResolvedValueOnce({
|
|
52
|
+
ok: true,
|
|
53
|
+
json: async () => GITHUB_RESPONSE,
|
|
54
|
+
} as Response)
|
|
55
|
+
|
|
56
|
+
const { GET } = await import('../setup-github-app-callback')
|
|
57
|
+
const ctx = makeCtx('abc123')
|
|
58
|
+
const res = await (GET as Function)(ctx)
|
|
59
|
+
|
|
60
|
+
expect(res.status).toBe(302)
|
|
61
|
+
expect(res.headers.get('location')).toBe('https://setzkasten.vercel.app/admin')
|
|
62
|
+
expect(ctx.cookies.set).toHaveBeenCalledOnce()
|
|
63
|
+
|
|
64
|
+
const stored = ctx.cookies._jar['sk_app_setup']
|
|
65
|
+
expect(stored).toBeDefined()
|
|
66
|
+
const cookieValue = JSON.parse(stored as string)
|
|
67
|
+
expect(cookieValue.appId).toBe('123456')
|
|
68
|
+
expect(cookieValue.slug).toBe('meine-cms-app')
|
|
69
|
+
expect(cookieValue.privateKey).toContain('RSA PRIVATE KEY')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('Response ist nicht immutable — Cookie-Header kann gesetzt werden', async () => {
|
|
73
|
+
vi.mocked(fetch).mockResolvedValueOnce({
|
|
74
|
+
ok: true,
|
|
75
|
+
json: async () => GITHUB_RESPONSE,
|
|
76
|
+
} as Response)
|
|
77
|
+
|
|
78
|
+
const { GET } = await import('../setup-github-app-callback')
|
|
79
|
+
const ctx = makeCtx('abc123')
|
|
80
|
+
const res = await (GET as Function)(ctx)
|
|
81
|
+
|
|
82
|
+
// Wenn Response.redirect() verwendet würde, wäre der Response immutable
|
|
83
|
+
// und das Setzen eines Headers würde "TypeError: immutable" werfen.
|
|
84
|
+
// Dieser Test schlägt fehl, wenn die falsche Implementierung verwendet wird.
|
|
85
|
+
expect(() => res.headers.set('X-Test', 'ok')).not.toThrow()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('leitet mit github-app-error=missing_code weiter wenn kein Code', async () => {
|
|
89
|
+
const { GET } = await import('../setup-github-app-callback')
|
|
90
|
+
const ctx = makeCtx(null)
|
|
91
|
+
const res = await (GET as Function)(ctx)
|
|
92
|
+
|
|
93
|
+
expect(res.status).toBe(302)
|
|
94
|
+
expect(res.headers.get('location')).toContain('github-app-error=missing_code')
|
|
95
|
+
expect(ctx.cookies.set).not.toHaveBeenCalled()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('leitet mit github-app-error=exchange_failed weiter wenn GitHub-API fehlschlägt', async () => {
|
|
99
|
+
vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 422, json: async () => ({}) } as Response)
|
|
100
|
+
|
|
101
|
+
const { GET } = await import('../setup-github-app-callback')
|
|
102
|
+
const ctx = makeCtx('badcode')
|
|
103
|
+
const res = await (GET as Function)(ctx)
|
|
104
|
+
|
|
105
|
+
expect(res.status).toBe(302)
|
|
106
|
+
expect(res.headers.get('location')).toContain('github-app-error=exchange_failed')
|
|
107
|
+
expect(ctx.cookies.set).not.toHaveBeenCalled()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('Redirect-URL enthält den echten Host aus x-forwarded-host', async () => {
|
|
111
|
+
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => GITHUB_RESPONSE } as Response)
|
|
112
|
+
|
|
113
|
+
const { GET } = await import('../setup-github-app-callback')
|
|
114
|
+
const ctx = makeCtx('abc123', { 'x-forwarded-host': 'meine-website.de', 'x-forwarded-proto': 'https' })
|
|
115
|
+
const res = await (GET as Function)(ctx)
|
|
116
|
+
|
|
117
|
+
expect(res.headers.get('location')).toBe('https://meine-website.de/admin')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('setup-github-app-callback – adminPath', () => {
|
|
122
|
+
beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
|
|
123
|
+
afterEach(() => { vi.restoreAllMocks(); vi.resetModules() })
|
|
124
|
+
|
|
125
|
+
it('verwendet /admin als Standard-Redirect wenn kein adminPath konfiguriert', async () => {
|
|
126
|
+
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => GITHUB_RESPONSE } as Response)
|
|
127
|
+
;(globalThis as any).__SETZKASTEN_CONFIG__ = undefined
|
|
128
|
+
|
|
129
|
+
const { GET } = await import('../setup-github-app-callback')
|
|
130
|
+
const res = await (GET as Function)(makeCtx('abc123'))
|
|
131
|
+
|
|
132
|
+
const location = new URL(res.headers.get('location') ?? '')
|
|
133
|
+
expect(location.pathname).toBe('/admin')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('verwendet konfigurierten adminPath für den Redirect', async () => {
|
|
137
|
+
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => GITHUB_RESPONSE } as Response)
|
|
138
|
+
;(globalThis as any).__SETZKASTEN_CONFIG__ = { adminPath: '/cms' }
|
|
139
|
+
|
|
140
|
+
const { GET } = await import('../setup-github-app-callback')
|
|
141
|
+
const res = await (GET as Function)(makeCtx('abc123'))
|
|
142
|
+
|
|
143
|
+
expect(res.headers.get('location')).toContain('/cms')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for setup-github-app-repos.ts and setup-github-app-branches.ts.
|
|
3
|
+
*
|
|
4
|
+
* Both routes are admin-only and read GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY
|
|
5
|
+
* from the env. We mock fetch to simulate GitHub responses.
|
|
6
|
+
*
|
|
7
|
+
* @vitest-environment node
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { generateKeyPairSync } from 'node:crypto'
|
|
11
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
12
|
+
|
|
13
|
+
const { privateKey: KEY } = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
14
|
+
const PRIVATE_KEY_PEM = KEY.export({ type: 'pkcs8', format: 'pem' }) as string
|
|
15
|
+
|
|
16
|
+
// The setup endpoints are admin-gated, so the placeholder "tok" string the
|
|
17
|
+
// tests used to pass would now be rejected before reaching the GitHub layer.
|
|
18
|
+
// Replace it with a real serialized admin session and let callers opt out
|
|
19
|
+
// via session: null when they want to exercise the unauthenticated branch.
|
|
20
|
+
const ADMIN_SESSION = JSON.stringify({
|
|
21
|
+
user: {
|
|
22
|
+
id: 'u1',
|
|
23
|
+
email: 'admin@example.com',
|
|
24
|
+
role: 'admin',
|
|
25
|
+
provider: 'github',
|
|
26
|
+
},
|
|
27
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
function makeCookies(session?: string) {
|
|
31
|
+
const jar: Record<string, string> = {}
|
|
32
|
+
// Treat the legacy "tok" placeholder as an admin session; explicit
|
|
33
|
+
// undefined keeps the cookie absent (401 path).
|
|
34
|
+
const resolved = session === 'tok' ? ADMIN_SESSION : session
|
|
35
|
+
if (resolved) jar.setzkasten_session = resolved
|
|
36
|
+
return {
|
|
37
|
+
get: vi.fn((name: string) => (jar[name] ? { value: jar[name] } : undefined)),
|
|
38
|
+
set: vi.fn(),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeCtx(opts: { session?: string; query?: string } = {}) {
|
|
43
|
+
const url = new URL(
|
|
44
|
+
`https://localhost/api/setzkasten/setup/github-app/repos${opts.query ?? ''}`,
|
|
45
|
+
)
|
|
46
|
+
return {
|
|
47
|
+
url,
|
|
48
|
+
cookies: makeCookies(opts.session),
|
|
49
|
+
request: new Request(url),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface MockResponse {
|
|
54
|
+
status?: number
|
|
55
|
+
body: unknown
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mockFetchSequence(steps: Array<(url: string) => MockResponse>) {
|
|
59
|
+
let i = 0
|
|
60
|
+
vi.stubGlobal(
|
|
61
|
+
'fetch',
|
|
62
|
+
vi.fn(async (input: string | URL) => {
|
|
63
|
+
const url = typeof input === 'string' ? input : input.toString()
|
|
64
|
+
const step = steps[i] ?? steps.at(-1)!
|
|
65
|
+
i += 1
|
|
66
|
+
const response = step(url)
|
|
67
|
+
const status = response.status ?? 200
|
|
68
|
+
return {
|
|
69
|
+
ok: status >= 200 && status < 300,
|
|
70
|
+
status,
|
|
71
|
+
json: async () => response.body,
|
|
72
|
+
} as Response
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('setup-github-app-repos', () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
process.env.GITHUB_APP_ID = '12345'
|
|
80
|
+
process.env.GITHUB_APP_PRIVATE_KEY = PRIVATE_KEY_PEM
|
|
81
|
+
})
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
vi.restoreAllMocks()
|
|
84
|
+
vi.resetModules()
|
|
85
|
+
delete process.env.GITHUB_APP_ID
|
|
86
|
+
delete process.env.GITHUB_APP_PRIVATE_KEY
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('rejects unauthenticated requests with 401', async () => {
|
|
90
|
+
const { GET } = await import('../setup-github-app-repos')
|
|
91
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal Astro context shape
|
|
92
|
+
const res = await GET(makeCtx() as any)
|
|
93
|
+
expect(res.status).toBe(401)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('returns 400 when the GitHub App env vars are missing', async () => {
|
|
97
|
+
delete process.env.GITHUB_APP_ID
|
|
98
|
+
const { GET } = await import('../setup-github-app-repos')
|
|
99
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal Astro context shape
|
|
100
|
+
const res = await GET(makeCtx({ session: 'tok' }) as any)
|
|
101
|
+
expect(res.status).toBe(400)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('returns the flattened repo list across installations', async () => {
|
|
105
|
+
mockFetchSequence([
|
|
106
|
+
// List installations
|
|
107
|
+
() => ({
|
|
108
|
+
body: [{ id: 100, account: { login: 'acme' } }],
|
|
109
|
+
}),
|
|
110
|
+
// Installation token
|
|
111
|
+
() => ({
|
|
112
|
+
body: { token: 'tok', expires_at: new Date(Date.now() + 3600_000).toISOString() },
|
|
113
|
+
}),
|
|
114
|
+
// Repos
|
|
115
|
+
() => ({
|
|
116
|
+
body: {
|
|
117
|
+
repositories: [
|
|
118
|
+
{
|
|
119
|
+
full_name: 'acme/site',
|
|
120
|
+
name: 'site',
|
|
121
|
+
owner: { login: 'acme' },
|
|
122
|
+
default_branch: 'main',
|
|
123
|
+
private: false,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
])
|
|
129
|
+
|
|
130
|
+
const { GET } = await import('../setup-github-app-repos')
|
|
131
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal Astro context shape
|
|
132
|
+
const res = await GET(makeCtx({ session: 'tok' }) as any)
|
|
133
|
+
expect(res.status).toBe(200)
|
|
134
|
+
const body = (await res.json()) as { repos: Array<Record<string, unknown>> }
|
|
135
|
+
expect(body.repos).toHaveLength(1)
|
|
136
|
+
expect(body.repos[0]).toMatchObject({
|
|
137
|
+
installationId: '100',
|
|
138
|
+
fullName: 'acme/site',
|
|
139
|
+
defaultBranch: 'main',
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('setup-github-app-branches', () => {
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
process.env.GITHUB_APP_ID = '12345'
|
|
147
|
+
process.env.GITHUB_APP_PRIVATE_KEY = PRIVATE_KEY_PEM
|
|
148
|
+
})
|
|
149
|
+
afterEach(() => {
|
|
150
|
+
vi.restoreAllMocks()
|
|
151
|
+
vi.resetModules()
|
|
152
|
+
delete process.env.GITHUB_APP_ID
|
|
153
|
+
delete process.env.GITHUB_APP_PRIVATE_KEY
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('returns 400 when ?repo or ?installation is missing', async () => {
|
|
157
|
+
const { GET } = await import('../setup-github-app-branches')
|
|
158
|
+
const url = new URL('https://localhost/api/setzkasten/setup/github-app/branches')
|
|
159
|
+
const ctx = {
|
|
160
|
+
url,
|
|
161
|
+
cookies: makeCookies('tok'),
|
|
162
|
+
request: new Request(url),
|
|
163
|
+
}
|
|
164
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal Astro context shape
|
|
165
|
+
const res = await GET(ctx as any)
|
|
166
|
+
expect(res.status).toBe(400)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('returns the branch list for a repo', async () => {
|
|
170
|
+
mockFetchSequence([
|
|
171
|
+
() => ({
|
|
172
|
+
body: { token: 'tok', expires_at: new Date(Date.now() + 3600_000).toISOString() },
|
|
173
|
+
}),
|
|
174
|
+
() => ({ body: [{ name: 'main' }, { name: 'develop' }] }),
|
|
175
|
+
])
|
|
176
|
+
|
|
177
|
+
const { GET } = await import('../setup-github-app-branches')
|
|
178
|
+
const url = new URL(
|
|
179
|
+
'https://localhost/api/setzkasten/setup/github-app/branches?installation=100&repo=acme/site',
|
|
180
|
+
)
|
|
181
|
+
const ctx = {
|
|
182
|
+
url,
|
|
183
|
+
cookies: makeCookies('tok'),
|
|
184
|
+
request: new Request(url),
|
|
185
|
+
}
|
|
186
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal Astro context shape
|
|
187
|
+
const res = await GET(ctx as any)
|
|
188
|
+
expect(res.status).toBe(200)
|
|
189
|
+
const body = (await res.json()) as { branches: string[] }
|
|
190
|
+
expect(body.branches).toEqual(['develop', 'main'])
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for setup-github-app.ts API route.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* GET 1. Nicht konfiguriert → { configured: false }
|
|
6
|
+
* GET 2. Konfiguriert (GITHUB_APP_* Vars gesetzt) → { configured: true, appId }
|
|
7
|
+
* POST 3. Gültige Credentials → { ok: true }
|
|
8
|
+
* POST 4. Fehlende installationId → 400
|
|
9
|
+
* POST 5. Ungültige Credentials (Token-Fetch schlägt fehl) → { ok: false, error }
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
13
|
+
import { generateKeyPairSync } from 'node:crypto'
|
|
14
|
+
|
|
15
|
+
const { privateKey: TEST_KEY } = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
16
|
+
const TEST_PEM = TEST_KEY.export({ type: 'pkcs8', format: 'pem' }) as string
|
|
17
|
+
|
|
18
|
+
type MockRequest = { json: () => Promise<unknown> }
|
|
19
|
+
type MockContext = { request: MockRequest }
|
|
20
|
+
|
|
21
|
+
function makeCtx(body: unknown): MockContext {
|
|
22
|
+
return { request: { json: async () => body } }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function setEnv(vars: Record<string, string | undefined>) {
|
|
26
|
+
for (const [k, v] of Object.entries(vars))
|
|
27
|
+
v === undefined ? delete process.env[k] : (process.env[k] = v)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('setup-github-app GET', () => {
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
setEnv({ GITHUB_APP_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined, GITHUB_APP_INSTALLATION_ID: undefined })
|
|
33
|
+
vi.resetModules()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('gibt configured: false zurück wenn App nicht konfiguriert', async () => {
|
|
37
|
+
const { GET } = await import('../setup-github-app')
|
|
38
|
+
const res = await (GET as Function)({})
|
|
39
|
+
const body = await res.json()
|
|
40
|
+
expect(res.status).toBe(200)
|
|
41
|
+
expect(body.configured).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
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' })
|
|
46
|
+
vi.resetModules()
|
|
47
|
+
const { GET } = await import('../setup-github-app')
|
|
48
|
+
const res = await (GET as Function)({})
|
|
49
|
+
const body = await res.json()
|
|
50
|
+
expect(res.status).toBe(200)
|
|
51
|
+
expect(body.configured).toBe(true)
|
|
52
|
+
expect(body.appId).toBe('99999')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('setup-github-app POST', () => {
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
vi.stubGlobal('fetch', vi.fn())
|
|
59
|
+
vi.resetModules()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
vi.restoreAllMocks()
|
|
64
|
+
vi.resetModules()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('gibt ok: true zurück wenn Verbindungstest erfolgreich', async () => {
|
|
68
|
+
vi.mocked(fetch).mockResolvedValueOnce({
|
|
69
|
+
ok: true,
|
|
70
|
+
json: async () => ({
|
|
71
|
+
token: 'ghs_test',
|
|
72
|
+
expires_at: new Date(Date.now() + 3600_000).toISOString(),
|
|
73
|
+
}),
|
|
74
|
+
} as Response)
|
|
75
|
+
|
|
76
|
+
const { POST } = await import('../setup-github-app')
|
|
77
|
+
const ctx = makeCtx({ appId: '123', privateKey: TEST_PEM, installationId: '456' })
|
|
78
|
+
const res = await (POST as Function)(ctx)
|
|
79
|
+
const body = await res.json()
|
|
80
|
+
|
|
81
|
+
expect(res.status).toBe(200)
|
|
82
|
+
expect(body.ok).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('gibt 400 zurück wenn installationId fehlt', async () => {
|
|
86
|
+
const { POST } = await import('../setup-github-app')
|
|
87
|
+
const ctx = makeCtx({ appId: '123', privateKey: TEST_PEM })
|
|
88
|
+
const res = await (POST as Function)(ctx)
|
|
89
|
+
expect(res.status).toBe(400)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('gibt ok: false zurück wenn GitHub-API-Aufruf fehlschlägt', async () => {
|
|
93
|
+
vi.mocked(fetch).mockResolvedValueOnce({
|
|
94
|
+
ok: false,
|
|
95
|
+
json: async () => ({ message: 'Bad credentials' }),
|
|
96
|
+
} as Response)
|
|
97
|
+
|
|
98
|
+
const { POST } = await import('../setup-github-app')
|
|
99
|
+
const ctx = makeCtx({ appId: '123', privateKey: TEST_PEM, installationId: '456' })
|
|
100
|
+
const res = await (POST as Function)(ctx)
|
|
101
|
+
const body = await res.json()
|
|
102
|
+
|
|
103
|
+
expect(res.status).toBe(400)
|
|
104
|
+
expect(body.ok).toBe(false)
|
|
105
|
+
expect(body.error).toBeTruthy()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
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 { resolveStorageConfigForRequest } from '../_storage-config'
|
|
8
|
+
import { __resetWebsiteResolverForTests } from '../_website-resolver'
|
|
9
|
+
|
|
10
|
+
const ENTRY: WebsiteEntry = {
|
|
11
|
+
id: 'site-a',
|
|
12
|
+
name: 'Site A',
|
|
13
|
+
repo: 'acme/site-a',
|
|
14
|
+
branch: 'develop',
|
|
15
|
+
previewOrigin: 'https://a.example.com',
|
|
16
|
+
githubApp: { appId: '11', installationId: '101' },
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeRegistry(entries: readonly WebsiteEntry[]) {
|
|
20
|
+
return {
|
|
21
|
+
list: vi.fn(async (): Promise<Result<readonly WebsiteEntry[]>> => ok(entries)),
|
|
22
|
+
get: vi.fn(async (id: string): Promise<Result<WebsiteEntry | null>> => {
|
|
23
|
+
return ok(entries.find((e) => e.id === id) ?? null)
|
|
24
|
+
}),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
|
|
30
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = undefined
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
afterEach(() => __resetWebsiteResolverForTests(null))
|
|
34
|
+
|
|
35
|
+
describe('resolveStorageConfigForRequest', () => {
|
|
36
|
+
it('derives owner/repo/branch from the resolved website entry', async () => {
|
|
37
|
+
__resetWebsiteResolverForTests({ mode: 'multi', registry: makeRegistry([ENTRY]) })
|
|
38
|
+
const req = new Request('https://cms.example.com/x', {
|
|
39
|
+
headers: { 'x-sk-website': 'site-a' },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const result = await resolveStorageConfigForRequest(req)
|
|
43
|
+
|
|
44
|
+
expect(result).not.toBeNull()
|
|
45
|
+
expect(result?.owner).toBe('acme')
|
|
46
|
+
expect(result?.repo).toBe('site-a')
|
|
47
|
+
expect(result?.branch).toBe('develop')
|
|
48
|
+
expect(result?.projectPrefix).toBe('')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns null when no website matches and no body override is given', async () => {
|
|
52
|
+
__resetWebsiteResolverForTests({ mode: 'multi', registry: makeRegistry([ENTRY]) })
|
|
53
|
+
const req = new Request('https://cms.example.com/x', {
|
|
54
|
+
headers: { 'x-sk-website': 'unknown' },
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const result = await resolveStorageConfigForRequest(req)
|
|
58
|
+
|
|
59
|
+
expect(result).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('honours an explicit body override even when resolver yields a website', async () => {
|
|
63
|
+
__resetWebsiteResolverForTests({ mode: 'multi', registry: makeRegistry([ENTRY]) })
|
|
64
|
+
const req = new Request('https://cms.example.com/x', {
|
|
65
|
+
headers: { 'x-sk-website': 'site-a' },
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const result = await resolveStorageConfigForRequest(req, {
|
|
69
|
+
owner: 'override',
|
|
70
|
+
repo: 'forced',
|
|
71
|
+
branch: 'main',
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
expect(result?.owner).toBe('override')
|
|
75
|
+
expect(result?.repo).toBe('forced')
|
|
76
|
+
expect(result?.branch).toBe('main')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generateKeyPairSync } from 'node:crypto'
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import {
|
|
8
|
+
__resetWebsiteResolverForTests,
|
|
9
|
+
bootstrapResolverFromGlobals,
|
|
10
|
+
listAllWebsites,
|
|
11
|
+
} from '../_website-resolver'
|
|
12
|
+
|
|
13
|
+
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
14
|
+
const PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
|
|
15
|
+
|
|
16
|
+
const REGISTRY = {
|
|
17
|
+
websites: [
|
|
18
|
+
{
|
|
19
|
+
id: 'a',
|
|
20
|
+
name: 'A',
|
|
21
|
+
repo: 'acme/a',
|
|
22
|
+
branch: 'main',
|
|
23
|
+
previewOrigin: 'https://a.example.com',
|
|
24
|
+
githubApp: { appId: '1', installationId: '101' },
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
__resetWebsiteResolverForTests(null)
|
|
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
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
|
|
36
|
+
storage: {
|
|
37
|
+
kind: 'standalone',
|
|
38
|
+
configRepo: 'acme/cms-config',
|
|
39
|
+
configBranch: 'main',
|
|
40
|
+
appId: '1',
|
|
41
|
+
installationId: '111',
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
__resetWebsiteResolverForTests(null)
|
|
48
|
+
vi.unstubAllEnvs()
|
|
49
|
+
vi.restoreAllMocks()
|
|
50
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('bootstrapResolverFromGlobals – standalone activation', () => {
|
|
54
|
+
it('returns the registry list from the config repo', async () => {
|
|
55
|
+
const fetchMock = vi.fn(async (url: string) => {
|
|
56
|
+
if (url.includes('/access_tokens')) {
|
|
57
|
+
return new Response(
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
token: 'gh_mock',
|
|
60
|
+
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
61
|
+
}),
|
|
62
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
if (url.includes('/contents/websites.json')) {
|
|
66
|
+
return new Response(
|
|
67
|
+
JSON.stringify({
|
|
68
|
+
type: 'file',
|
|
69
|
+
content: Buffer.from(JSON.stringify(REGISTRY)).toString('base64'),
|
|
70
|
+
sha: 'abc',
|
|
71
|
+
}),
|
|
72
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`unexpected URL: ${url}`)
|
|
76
|
+
})
|
|
77
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
78
|
+
|
|
79
|
+
bootstrapResolverFromGlobals()
|
|
80
|
+
|
|
81
|
+
const result = await listAllWebsites()
|
|
82
|
+
expect(result.ok).toBe(true)
|
|
83
|
+
if (result.ok) expect(result.value.map((w) => w.id)).toEqual(['a'])
|
|
84
|
+
})
|
|
85
|
+
})
|