@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@setzkasten-cms/astro-admin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Setzkasten Admin-UI, Init-Wizard und Adoptions-Pipeline für Astro",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"headless-cms"
|
|
22
22
|
],
|
|
23
23
|
"exports": {
|
|
24
|
+
"./auth-setzkasten-login": "./src/api-routes/auth-setzkasten-login.ts",
|
|
25
|
+
"./global-config": "./src/api-routes/global-config.ts",
|
|
24
26
|
"./auth-login": "./src/api-routes/auth-login.ts",
|
|
25
27
|
"./auth-callback": "./src/api-routes/auth-callback.ts",
|
|
26
28
|
"./auth-logout": "./src/api-routes/auth-logout.ts",
|
|
@@ -44,6 +46,21 @@
|
|
|
44
46
|
"./section-commit-pending": "./src/api-routes/section-commit-pending.ts",
|
|
45
47
|
"./section-delete": "./src/api-routes/section-delete.ts",
|
|
46
48
|
"./section-duplicate": "./src/api-routes/section-duplicate.ts",
|
|
49
|
+
"./updater-register": "./src/api-routes/updater-register.ts",
|
|
50
|
+
"./updater-check": "./src/api-routes/updater-check.ts",
|
|
51
|
+
"./updater-transfer": "./src/api-routes/updater-transfer.ts",
|
|
52
|
+
"./updater-unbind": "./src/api-routes/updater-unbind.ts",
|
|
53
|
+
"./editors": "./src/api-routes/editors.ts",
|
|
54
|
+
"./setup-github-app": "./src/api-routes/setup-github-app.ts",
|
|
55
|
+
"./setup-github-app-callback": "./src/api-routes/setup-github-app-callback.ts",
|
|
56
|
+
"./setup-github-app-installed": "./src/api-routes/setup-github-app-installed.ts",
|
|
57
|
+
"./setup-github-app-bounce": "./src/api-routes/setup-github-app-bounce.ts",
|
|
58
|
+
"./setup-github-app-repos": "./src/api-routes/setup-github-app-repos.ts",
|
|
59
|
+
"./setup-github-app-branches": "./src/api-routes/setup-github-app-branches.ts",
|
|
60
|
+
"./websites-list": "./src/api-routes/websites-list.ts",
|
|
61
|
+
"./websites-add": "./src/api-routes/websites-add.ts",
|
|
62
|
+
"./websites-remove": "./src/api-routes/websites-remove.ts",
|
|
63
|
+
"./migrate-to-multi": "./src/api-routes/migrate-to-multi.ts",
|
|
47
64
|
"./admin-page": "./src/admin-page.astro"
|
|
48
65
|
},
|
|
49
66
|
"devDependencies": {
|
|
@@ -51,11 +68,11 @@
|
|
|
51
68
|
},
|
|
52
69
|
"dependencies": {
|
|
53
70
|
"@astrojs/compiler": "^3.0.0",
|
|
54
|
-
"@setzkasten-cms/auth": "
|
|
55
|
-
"@setzkasten-cms/catalog": "
|
|
56
|
-
"@setzkasten-cms/
|
|
57
|
-
"@setzkasten-cms/
|
|
58
|
-
"@setzkasten-cms/
|
|
71
|
+
"@setzkasten-cms/auth": "1.1.0",
|
|
72
|
+
"@setzkasten-cms/catalog": "1.1.0",
|
|
73
|
+
"@setzkasten-cms/github-adapter": "1.1.0",
|
|
74
|
+
"@setzkasten-cms/core": "1.1.0",
|
|
75
|
+
"@setzkasten-cms/ui": "1.1.0"
|
|
59
76
|
},
|
|
60
77
|
"peerDependencies": {
|
|
61
78
|
"astro": "^5.0.0",
|
package/src/admin-page.astro
CHANGED
|
@@ -69,10 +69,7 @@
|
|
|
69
69
|
try {
|
|
70
70
|
const injected = (globalThis as any).__SETZKASTEN_CONFIG__ ?? {}
|
|
71
71
|
|
|
72
|
-
const providers: Array<'github' | 'google'> = []
|
|
73
|
-
if (injected.hasGitHub) providers.push('github')
|
|
74
|
-
if (injected.hasGoogle) providers.push('google')
|
|
75
|
-
if (providers.length === 0) providers.push('github')
|
|
72
|
+
const providers: Array<'github' | 'google'> = ['github']
|
|
76
73
|
|
|
77
74
|
// Fetch the full user config from server
|
|
78
75
|
let userConfig: any = null
|
|
@@ -81,12 +78,15 @@
|
|
|
81
78
|
if (res.ok) userConfig = await res.json()
|
|
82
79
|
} catch {}
|
|
83
80
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
// providers is always derived from injected flags (license-aware),
|
|
82
|
+
// so we override auth.providers regardless of what userConfig says.
|
|
83
|
+
const skConfig = {
|
|
84
|
+
storage: { kind: 'local' as const },
|
|
87
85
|
theme: {},
|
|
88
86
|
products: {},
|
|
89
87
|
collections: {},
|
|
88
|
+
...(userConfig ?? {}),
|
|
89
|
+
auth: { ...(userConfig?.auth ?? {}), providers },
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
// Storage params come from the config API (server-injected via SSR)
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
repo,
|
|
112
112
|
branch,
|
|
113
113
|
assetsPath,
|
|
114
|
-
publicUrlPrefix
|
|
114
|
+
// publicUrlPrefix is auto-derived from assetsPath by ProxyAssetStore
|
|
115
115
|
})
|
|
116
116
|
|
|
117
117
|
const auth = {
|
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
createElement(AdminApp)
|
|
132
132
|
)
|
|
133
133
|
)
|
|
134
|
+
|
|
134
135
|
} catch (error) {
|
|
135
136
|
console.error('[setzkasten] Boot failed:', error)
|
|
136
137
|
root.innerHTML = `
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { parseSession, guardPageAccess } from '../_auth-guard'
|
|
3
|
+
import type { AuthSession, SetzKastenConfig } from '@setzkasten-cms/core'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function adminSession(): AuthSession {
|
|
10
|
+
return { user: { id: '1', email: 'admin@example.com', provider: 'github', role: 'admin' }, expiresAt: Date.now() + 86400_000 }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function editorSession(email = 'editor@example.com'): AuthSession {
|
|
14
|
+
return { user: { id: '2', email, provider: 'google', role: 'editor' }, expiresAt: Date.now() + 86400_000 }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const baseConfig: SetzKastenConfig = {
|
|
18
|
+
storage: { kind: 'local' as const },
|
|
19
|
+
auth: { providers: ['github'] },
|
|
20
|
+
products: {},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// parseSession
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
describe('parseSession', () => {
|
|
28
|
+
it('returns null for undefined input', () => {
|
|
29
|
+
expect(parseSession(undefined)).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns null for invalid JSON', () => {
|
|
33
|
+
expect(parseSession('not-json')).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('parses a valid session cookie', () => {
|
|
37
|
+
const session = adminSession()
|
|
38
|
+
expect(parseSession(JSON.stringify(session))).toEqual(session)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// guardPageAccess – dynamic editors
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
vi.mock('../editors', () => ({
|
|
47
|
+
readEditorsFileStatus: vi.fn(),
|
|
48
|
+
}))
|
|
49
|
+
|
|
50
|
+
vi.mock('../_storage-config', () => ({
|
|
51
|
+
resolveStorageConfig: vi.fn(() => ({ owner: 'test', repo: 'test', branch: 'main' })),
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
vi.mock('../_github-token', () => ({
|
|
55
|
+
resolveConfigRepoToken: vi.fn(async () => ({ ok: true, value: 'ghs_mock' })),
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
const { readEditorsFileStatus } = await import('../editors')
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
vi.mocked(readEditorsFileStatus).mockReset()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('guardPageAccess – no session', () => {
|
|
65
|
+
it('returns 401 for missing session', async () => {
|
|
66
|
+
const res = await guardPageAccess(null, 'home', baseConfig)
|
|
67
|
+
expect(res?.status).toBe(401)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('guardPageAccess – admin', () => {
|
|
72
|
+
it('always passes for admin regardless of editors', async () => {
|
|
73
|
+
vi.mocked(readEditorsFileStatus).mockResolvedValue({
|
|
74
|
+
kind: 'present',
|
|
75
|
+
editors: [{ email: 'other@example.com' }],
|
|
76
|
+
})
|
|
77
|
+
const res = await guardPageAccess(adminSession(), 'home', baseConfig)
|
|
78
|
+
expect(res).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('guardPageAccess – dynamic editors', () => {
|
|
83
|
+
it('allows editor access when listed in _editors.json', async () => {
|
|
84
|
+
vi.mocked(readEditorsFileStatus).mockResolvedValue({
|
|
85
|
+
kind: 'present',
|
|
86
|
+
editors: [{ email: 'editor@example.com', pages: ['home', 'about'] }],
|
|
87
|
+
})
|
|
88
|
+
const res = await guardPageAccess(editorSession('editor@example.com'), 'home', baseConfig)
|
|
89
|
+
expect(res).toBeNull()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('denies editor access when page not listed in _editors.json', async () => {
|
|
93
|
+
vi.mocked(readEditorsFileStatus).mockResolvedValue({
|
|
94
|
+
kind: 'present',
|
|
95
|
+
editors: [{ email: 'editor@example.com', pages: ['about'] }],
|
|
96
|
+
})
|
|
97
|
+
const res = await guardPageAccess(editorSession('editor@example.com'), 'home', baseConfig)
|
|
98
|
+
expect(res?.status).toBe(403)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('allows all pages when editor has no page restriction', async () => {
|
|
102
|
+
vi.mocked(readEditorsFileStatus).mockResolvedValue({
|
|
103
|
+
kind: 'present',
|
|
104
|
+
editors: [{ email: 'editor@example.com' }],
|
|
105
|
+
})
|
|
106
|
+
const res = await guardPageAccess(editorSession('editor@example.com'), 'secret-page', baseConfig)
|
|
107
|
+
expect(res).toBeNull()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('allows access when _editors.json is genuinely absent (404)', async () => {
|
|
111
|
+
vi.mocked(readEditorsFileStatus).mockResolvedValue({ kind: 'absent' })
|
|
112
|
+
const res = await guardPageAccess(editorSession(), 'home', baseConfig)
|
|
113
|
+
expect(res).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('returns 503 when the editors fetch errors out (fail-closed)', async () => {
|
|
117
|
+
vi.mocked(readEditorsFileStatus).mockResolvedValue({
|
|
118
|
+
kind: 'error',
|
|
119
|
+
message: 'GitHub returned 500',
|
|
120
|
+
})
|
|
121
|
+
const res = await guardPageAccess(editorSession(), 'home', baseConfig)
|
|
122
|
+
expect(res?.status).toBe(503)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('denies when dynamic editors list is empty, regardless of config', async () => {
|
|
126
|
+
vi.mocked(readEditorsFileStatus).mockResolvedValue({
|
|
127
|
+
kind: 'present',
|
|
128
|
+
editors: [],
|
|
129
|
+
})
|
|
130
|
+
// Dynamic list is empty → denied
|
|
131
|
+
const res = await guardPageAccess(editorSession('editor@example.com'), 'home', baseConfig)
|
|
132
|
+
expect(res?.status).toBe(403)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for commit message trailer helpers.
|
|
3
|
+
* @vitest-environment node
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest'
|
|
7
|
+
import { withTrailers, SETZKASTEN_CO_AUTHOR } from '../_commit-trailers'
|
|
8
|
+
|
|
9
|
+
describe('SETZKASTEN_CO_AUTHOR', () => {
|
|
10
|
+
it('is a valid Co-authored-by trailer', () => {
|
|
11
|
+
expect(SETZKASTEN_CO_AUTHOR).toMatch(/^Co-authored-by: .+ <.+>$/)
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('withTrailers', () => {
|
|
16
|
+
it('appends the Setzkasten co-author trailer to any message', () => {
|
|
17
|
+
const result = withTrailers('content: update hero section on index')
|
|
18
|
+
expect(result).toContain(SETZKASTEN_CO_AUTHOR)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('separates the trailer block with a blank line', () => {
|
|
22
|
+
const result = withTrailers('content: update hero section on index')
|
|
23
|
+
expect(result).toContain('\n\n')
|
|
24
|
+
const [body, trailers] = result.split('\n\n')
|
|
25
|
+
expect(body).toBe('content: update hero section on index')
|
|
26
|
+
expect(trailers).toContain('Co-authored-by:')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('does not add editor trailer when editorEmail is not provided', () => {
|
|
30
|
+
const result = withTrailers('chore: something')
|
|
31
|
+
const lines = result.split('\n').filter(l => l.startsWith('Co-authored-by:'))
|
|
32
|
+
expect(lines).toHaveLength(1)
|
|
33
|
+
expect(lines[0]).toBe(SETZKASTEN_CO_AUTHOR)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does not add editor trailer when editorEmail is null', () => {
|
|
37
|
+
const result = withTrailers('chore: something', null)
|
|
38
|
+
const lines = result.split('\n').filter(l => l.startsWith('Co-authored-by:'))
|
|
39
|
+
expect(lines).toHaveLength(1)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('adds editor Co-authored-by when editorEmail is provided', () => {
|
|
43
|
+
const result = withTrailers('content: update', 'jane.doe@example.com')
|
|
44
|
+
const lines = result.split('\n').filter(l => l.startsWith('Co-authored-by:'))
|
|
45
|
+
expect(lines).toHaveLength(2)
|
|
46
|
+
expect(lines[1]).toContain('jane.doe@example.com')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('derives a readable name from the editor email local-part', () => {
|
|
50
|
+
const result = withTrailers('content: update', 'jane.doe@example.com')
|
|
51
|
+
expect(result).toContain('Co-authored-by: Jane Doe <jane.doe@example.com>')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('handles hyphen-separated names in email', () => {
|
|
55
|
+
const result = withTrailers('content: update', 'anna-lena@example.com')
|
|
56
|
+
expect(result).toContain('Co-authored-by: Anna Lena <anna-lena@example.com>')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('handles underscore-separated names in email', () => {
|
|
60
|
+
const result = withTrailers('content: update', 'max_mustermann@example.com')
|
|
61
|
+
expect(result).toContain('Co-authored-by: Max Mustermann <max_mustermann@example.com>')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('preserves the original message as the first line', () => {
|
|
65
|
+
const msg = 'content: add new section on about'
|
|
66
|
+
const result = withTrailers(msg, 'editor@example.com')
|
|
67
|
+
expect(result.startsWith(msg)).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the GitHub file read cache.
|
|
3
|
+
* @vitest-environment node
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
7
|
+
import { cachedFetch, invalidateCache } from '../_github-cache'
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
invalidateCache('test-key')
|
|
11
|
+
vi.useRealTimers()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('cachedFetch', () => {
|
|
15
|
+
it('calls fetcher on first access (cache miss)', async () => {
|
|
16
|
+
const fetcher = vi.fn().mockResolvedValue('data-v1')
|
|
17
|
+
const result = await cachedFetch('test-key', 60_000, fetcher)
|
|
18
|
+
expect(result).toBe('data-v1')
|
|
19
|
+
expect(fetcher).toHaveBeenCalledOnce()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns cached value without calling fetcher again (cache hit)', async () => {
|
|
23
|
+
const fetcher = vi.fn().mockResolvedValue('data-v1')
|
|
24
|
+
await cachedFetch('test-key', 60_000, fetcher)
|
|
25
|
+
const result = await cachedFetch('test-key', 60_000, fetcher)
|
|
26
|
+
expect(result).toBe('data-v1')
|
|
27
|
+
expect(fetcher).toHaveBeenCalledOnce()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('re-fetches after TTL expires', async () => {
|
|
31
|
+
vi.useFakeTimers()
|
|
32
|
+
const fetcher = vi.fn().mockResolvedValue('data-v1')
|
|
33
|
+
await cachedFetch('test-key', 1_000, fetcher)
|
|
34
|
+
|
|
35
|
+
vi.advanceTimersByTime(1_001)
|
|
36
|
+
fetcher.mockResolvedValue('data-v2')
|
|
37
|
+
const result = await cachedFetch('test-key', 1_000, fetcher)
|
|
38
|
+
|
|
39
|
+
expect(result).toBe('data-v2')
|
|
40
|
+
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns stale value on fetch error (stale-on-error)', async () => {
|
|
44
|
+
vi.useFakeTimers()
|
|
45
|
+
const fetcher = vi.fn().mockResolvedValue('data-v1')
|
|
46
|
+
await cachedFetch('test-key', 1_000, fetcher)
|
|
47
|
+
|
|
48
|
+
vi.advanceTimersByTime(1_001)
|
|
49
|
+
fetcher.mockRejectedValue(new Error('GitHub API down'))
|
|
50
|
+
const result = await cachedFetch('test-key', 1_000, fetcher)
|
|
51
|
+
|
|
52
|
+
expect(result).toBe('data-v1')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('throws when fetcher fails and no stale value exists', async () => {
|
|
56
|
+
const fetcher = vi.fn().mockRejectedValue(new Error('GitHub API down'))
|
|
57
|
+
await expect(cachedFetch('test-key', 60_000, fetcher)).rejects.toThrow('GitHub API down')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('caches null values (file not found is a valid result)', async () => {
|
|
61
|
+
const fetcher = vi.fn().mockResolvedValue(null)
|
|
62
|
+
await cachedFetch('test-key', 60_000, fetcher)
|
|
63
|
+
const result = await cachedFetch('test-key', 60_000, fetcher)
|
|
64
|
+
expect(result).toBeNull()
|
|
65
|
+
expect(fetcher).toHaveBeenCalledOnce()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('uses separate cache entries per key', async () => {
|
|
69
|
+
invalidateCache('key-a')
|
|
70
|
+
invalidateCache('key-b')
|
|
71
|
+
const fetcherA = vi.fn().mockResolvedValue('a')
|
|
72
|
+
const fetcherB = vi.fn().mockResolvedValue('b')
|
|
73
|
+
const [a, b] = await Promise.all([
|
|
74
|
+
cachedFetch('key-a', 60_000, fetcherA),
|
|
75
|
+
cachedFetch('key-b', 60_000, fetcherB),
|
|
76
|
+
])
|
|
77
|
+
expect(a).toBe('a')
|
|
78
|
+
expect(b).toBe('b')
|
|
79
|
+
invalidateCache('key-a')
|
|
80
|
+
invalidateCache('key-b')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('invalidateCache', () => {
|
|
85
|
+
it('forces re-fetch after invalidation', async () => {
|
|
86
|
+
const fetcher = vi.fn().mockResolvedValue('data-v1')
|
|
87
|
+
await cachedFetch('test-key', 60_000, fetcher)
|
|
88
|
+
|
|
89
|
+
invalidateCache('test-key')
|
|
90
|
+
fetcher.mockResolvedValue('data-v2')
|
|
91
|
+
const result = await cachedFetch('test-key', 60_000, fetcher)
|
|
92
|
+
|
|
93
|
+
expect(result).toBe('data-v2')
|
|
94
|
+
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('is a no-op for unknown keys', () => {
|
|
98
|
+
expect(() => invalidateCache('nonexistent-key')).not.toThrow()
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generateKeyPairSync } from 'node:crypto'
|
|
6
|
+
import { type Result, type WebsiteEntry, ok } from '@setzkasten-cms/core'
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
import { resolveGitHubTokenForRequest } from '../_github-token'
|
|
9
|
+
import { __resetWebsiteResolverForTests } from '../_website-resolver'
|
|
10
|
+
|
|
11
|
+
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
12
|
+
const PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
|
|
13
|
+
|
|
14
|
+
const ENTRY_A: WebsiteEntry = {
|
|
15
|
+
id: 'a',
|
|
16
|
+
name: 'A',
|
|
17
|
+
repo: 'acme/a',
|
|
18
|
+
branch: 'main',
|
|
19
|
+
previewOrigin: 'https://a.example.com',
|
|
20
|
+
githubApp: { appId: '11', installationId: '101' },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ENTRY_B: WebsiteEntry = {
|
|
24
|
+
id: 'b',
|
|
25
|
+
name: 'B',
|
|
26
|
+
repo: 'acme/b',
|
|
27
|
+
branch: 'main',
|
|
28
|
+
previewOrigin: 'https://b.example.com',
|
|
29
|
+
githubApp: { appId: '11', installationId: '202' },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeRegistry(entries: readonly WebsiteEntry[]) {
|
|
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
|
+
beforeEach(() => {
|
|
42
|
+
vi.unstubAllEnvs()
|
|
43
|
+
vi.stubEnv('GITHUB_APP_PRIVATE_KEY', PEM)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
__resetWebsiteResolverForTests(null)
|
|
48
|
+
vi.restoreAllMocks()
|
|
49
|
+
vi.unstubAllEnvs()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('resolveGitHubTokenForRequest', () => {
|
|
53
|
+
it('uses the resolved website installation id when calling GitHub', async () => {
|
|
54
|
+
__resetWebsiteResolverForTests({
|
|
55
|
+
mode: 'multi',
|
|
56
|
+
registry: makeRegistry([ENTRY_A, ENTRY_B]),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({
|
|
60
|
+
ok: true,
|
|
61
|
+
json: async () => ({
|
|
62
|
+
token: 'ghs_token_for_b',
|
|
63
|
+
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
64
|
+
}),
|
|
65
|
+
}))
|
|
66
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
67
|
+
|
|
68
|
+
const req = new Request('https://cms.example.com/x', {
|
|
69
|
+
headers: { 'x-sk-website': 'b' },
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const result = await resolveGitHubTokenForRequest(req)
|
|
73
|
+
|
|
74
|
+
expect(result.ok).toBe(true)
|
|
75
|
+
if (result.ok) expect(result.value).toBe('ghs_token_for_b')
|
|
76
|
+
|
|
77
|
+
const calledUrl = fetchMock.mock.calls[0]?.[0]
|
|
78
|
+
expect(calledUrl ?? '').toContain('/app/installations/202/access_tokens')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('returns auth-error when resolver fails', async () => {
|
|
82
|
+
__resetWebsiteResolverForTests({
|
|
83
|
+
mode: 'multi',
|
|
84
|
+
registry: makeRegistry([ENTRY_A]),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const req = new Request('https://cms.example.com/x', {
|
|
88
|
+
headers: { 'x-sk-website': 'unknown' },
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const result = await resolveGitHubTokenForRequest(req)
|
|
92
|
+
|
|
93
|
+
expect(result.ok).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('returns auth-error when GITHUB_APP_PRIVATE_KEY is missing', async () => {
|
|
97
|
+
__resetWebsiteResolverForTests({
|
|
98
|
+
mode: 'multi',
|
|
99
|
+
registry: makeRegistry([ENTRY_A]),
|
|
100
|
+
})
|
|
101
|
+
vi.unstubAllEnvs()
|
|
102
|
+
|
|
103
|
+
const req = new Request('https://cms.example.com/x', {
|
|
104
|
+
headers: { 'x-sk-website': 'a' },
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const result = await resolveGitHubTokenForRequest(req)
|
|
108
|
+
|
|
109
|
+
expect(result.ok).toBe(false)
|
|
110
|
+
if (!result.ok) expect(result.error.type).toBe('auth')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for _github-token.ts
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* 1. GitHub App vars gesetzt → gibt Installation Access Token zurück
|
|
6
|
+
* 2. Nicht alle Vars gesetzt → auth error Result
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
10
|
+
import { generateKeyPairSync } from 'node:crypto'
|
|
11
|
+
|
|
12
|
+
const { privateKey: TEST_KEY } = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
13
|
+
const TEST_PRIVATE_KEY_PEM = TEST_KEY.export({ type: 'pkcs8', format: 'pem' }) as string
|
|
14
|
+
|
|
15
|
+
function setEnv(vars: Record<string, string | undefined>) {
|
|
16
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
17
|
+
if (value === undefined) {
|
|
18
|
+
delete process.env[key]
|
|
19
|
+
} else {
|
|
20
|
+
process.env[key] = value
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clearAppEnv() {
|
|
26
|
+
setEnv({
|
|
27
|
+
GITHUB_APP_ID: undefined,
|
|
28
|
+
GITHUB_APP_PRIVATE_KEY: undefined,
|
|
29
|
+
GITHUB_APP_INSTALLATION_ID: undefined,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('resolveConfigRepoToken', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
clearAppEnv()
|
|
36
|
+
vi.stubGlobal('fetch', vi.fn())
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
clearAppEnv()
|
|
41
|
+
vi.restoreAllMocks()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('gibt Installation-Token zurück wenn alle GITHUB_APP_*-Vars gesetzt sind', async () => {
|
|
45
|
+
setEnv({
|
|
46
|
+
GITHUB_APP_ID: '123456',
|
|
47
|
+
GITHUB_APP_PRIVATE_KEY: TEST_PRIVATE_KEY_PEM,
|
|
48
|
+
GITHUB_APP_INSTALLATION_ID: '42000000',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
vi.mocked(fetch).mockResolvedValueOnce({
|
|
52
|
+
ok: true,
|
|
53
|
+
json: async () => ({
|
|
54
|
+
token: 'ghs_installation_token',
|
|
55
|
+
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
56
|
+
}),
|
|
57
|
+
} as Response)
|
|
58
|
+
|
|
59
|
+
const { resolveConfigRepoToken } = await import('../_github-token')
|
|
60
|
+
const result = await resolveConfigRepoToken()
|
|
61
|
+
|
|
62
|
+
expect(result.ok).toBe(true)
|
|
63
|
+
if (result.ok) expect(result.value).toBe('ghs_installation_token')
|
|
64
|
+
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
|
65
|
+
expect.stringContaining('/app/installations/42000000/access_tokens'),
|
|
66
|
+
expect.objectContaining({ method: 'POST' }),
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('gibt auth error zurück wenn GITHUB_APP_*-Vars fehlen', async () => {
|
|
71
|
+
const { resolveConfigRepoToken } = await import('../_github-token')
|
|
72
|
+
const result = await resolveConfigRepoToken()
|
|
73
|
+
|
|
74
|
+
expect(result.ok).toBe(false)
|
|
75
|
+
if (!result.ok) expect(result.error.type).toBe('auth')
|
|
76
|
+
expect(vi.mocked(fetch)).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('../_storage-config', () => ({
|
|
4
|
+
resolveStorageConfig: vi.fn(() => ({ owner: 'o', repo: 'r', branch: 'main' })),
|
|
5
|
+
}))
|
|
6
|
+
|
|
7
|
+
vi.stubGlobal('__SETZKASTEN_CONFIG__', { storage: { contentPath: 'content' } })
|
|
8
|
+
|
|
9
|
+
describe('GlobalConfig – theme field', () => {
|
|
10
|
+
it('GlobalConfig type accepts a theme object', async () => {
|
|
11
|
+
const { } = await import('../global-config')
|
|
12
|
+
// Type-level check: if this compiles the type is correct
|
|
13
|
+
const cfg = {
|
|
14
|
+
theme: { primaryColor: '#ff0000', brandName: 'Acme', logo: '/logo.png' },
|
|
15
|
+
}
|
|
16
|
+
expect(cfg.theme.primaryColor).toBe('#ff0000')
|
|
17
|
+
expect(cfg.theme.brandName).toBe('Acme')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('GlobalConfig theme fields are all optional', async () => {
|
|
21
|
+
const cfg = { theme: {} }
|
|
22
|
+
expect(cfg.theme).toBeDefined()
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('config.ts – theme merge', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.resetModules()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('merges global theme over static config theme', async () => {
|
|
32
|
+
vi.doMock('../global-config', () => ({
|
|
33
|
+
readGlobalConfig: vi.fn(async () => ({
|
|
34
|
+
theme: { primaryColor: '#abc123', brandName: 'Dynamic Brand' },
|
|
35
|
+
})),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
vi.stubGlobal('__SETZKASTEN_FULL_CONFIG__', {
|
|
39
|
+
theme: { primaryColor: '#ffffff', brandName: 'Static Brand', logo: '/logo.png' },
|
|
40
|
+
auth: { providers: ['github'] },
|
|
41
|
+
products: {},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const { GET } = await import('../config')
|
|
45
|
+
const res = await GET({} as any)
|
|
46
|
+
const body = await res.json() as any
|
|
47
|
+
|
|
48
|
+
expect(body.theme.primaryColor).toBe('#abc123')
|
|
49
|
+
expect(body.theme.brandName).toBe('Dynamic Brand')
|
|
50
|
+
expect(body.theme.logo).toBe('/logo.png') // static fallback preserved
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('uses static theme when global config has no theme', async () => {
|
|
54
|
+
vi.doMock('../global-config', () => ({
|
|
55
|
+
readGlobalConfig: vi.fn(async () => ({ firebaseConfig: null })),
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
vi.stubGlobal('__SETZKASTEN_FULL_CONFIG__', {
|
|
59
|
+
theme: { primaryColor: '#ffffff', brandName: 'Static Brand' },
|
|
60
|
+
auth: { providers: ['github'] },
|
|
61
|
+
products: {},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const { GET } = await import('../config')
|
|
65
|
+
const res = await GET({} as any)
|
|
66
|
+
const body = await res.json() as any
|
|
67
|
+
|
|
68
|
+
expect(body.theme.primaryColor).toBe('#ffffff')
|
|
69
|
+
expect(body.theme.brandName).toBe('Static Brand')
|
|
70
|
+
})
|
|
71
|
+
})
|