@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,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for resolveFullConfig() — the helper that scan-page uses to read the
|
|
3
|
+
* full Setzkasten config in API-route context.
|
|
4
|
+
*
|
|
5
|
+
* Why this matters: when /api/setzkasten/init/scan-page is invoked as a
|
|
6
|
+
* standalone serverless function (Vercel cold-start), the `page-ssr`
|
|
7
|
+
* injectScript that sets globalThis.__SETZKASTEN_FULL_CONFIG__ never ran.
|
|
8
|
+
* Without a fallback, managedSections becomes an empty Map and every
|
|
9
|
+
* already-adopted section (e.g. _layout_header, _layout_footer) is offered
|
|
10
|
+
* for re-adoption. The fix: a Vite build-time define inlines the config
|
|
11
|
+
* into compiled API routes; resolveFullConfig() prefers the build-time
|
|
12
|
+
* constant and falls back to globalThis for local dev / tests.
|
|
13
|
+
*
|
|
14
|
+
* @vitest-environment node
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
18
|
+
import { resolveFullConfig } from '../init-scan-page'
|
|
19
|
+
|
|
20
|
+
const SAMPLE_CONFIG = {
|
|
21
|
+
storage: { kind: 'github' as const },
|
|
22
|
+
products: {
|
|
23
|
+
website: {
|
|
24
|
+
label: 'Website',
|
|
25
|
+
sections: {
|
|
26
|
+
_layout_header: { label: 'header', icon: 'panel-top', fields: { items: { type: 'array' } } },
|
|
27
|
+
_layout_footer: { label: 'footer', icon: 'file-text', fields: { description: { type: 'text' } } },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
delete (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
delete (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('resolveFullConfig', () => {
|
|
42
|
+
it('returns undefined when neither build-define nor globalThis is set', () => {
|
|
43
|
+
expect(resolveFullConfig()).toBeUndefined()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('reads config from globalThis.__SETZKASTEN_FULL_CONFIG__', () => {
|
|
47
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = SAMPLE_CONFIG
|
|
48
|
+
const result = resolveFullConfig()
|
|
49
|
+
expect(result).toBeDefined()
|
|
50
|
+
expect(result?.products.website?.sections._layout_header).toBeDefined()
|
|
51
|
+
expect(result?.products.website?.sections._layout_footer).toBeDefined()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('exposes section keys so managedSections can detect adopted layout regions', () => {
|
|
55
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = SAMPLE_CONFIG
|
|
56
|
+
const result = resolveFullConfig()
|
|
57
|
+
const keys = result ? Object.keys(result.products.website!.sections) : []
|
|
58
|
+
expect(keys).toContain('_layout_header')
|
|
59
|
+
expect(keys).toContain('_layout_footer')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { resolveLicenseTier } from '../_license-tier'
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.unstubAllEnvs()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.unstubAllEnvs()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('resolveLicenseTier', () => {
|
|
17
|
+
it('returns free when no license key is configured', () => {
|
|
18
|
+
expect(resolveLicenseTier()).toBe('free')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns pro for an SK-PRO-* key', () => {
|
|
22
|
+
vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-PRO-AAAAAAAA-BBBBBBBB-CCCCCCCC')
|
|
23
|
+
expect(resolveLicenseTier()).toBe('pro')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns enterprise for an SK-ENT-* key', () => {
|
|
27
|
+
vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-ENT-AAAAAAAA-BBBBBBBB-CCCCCCCC')
|
|
28
|
+
expect(resolveLicenseTier()).toBe('enterprise')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('returns free for an unrecognised prefix', () => {
|
|
32
|
+
vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-FAKE-XXXXXXXX')
|
|
33
|
+
expect(resolveLicenseTier()).toBe('free')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns free for empty string', () => {
|
|
37
|
+
vi.stubEnv('SETZKASTEN_LICENSE_KEY', '')
|
|
38
|
+
expect(resolveLicenseTier()).toBe('free')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('trims surrounding whitespace before deciding', () => {
|
|
42
|
+
vi.stubEnv('SETZKASTEN_LICENSE_KEY', ' SK-PRO-AAAA-BBBB-CCCC ')
|
|
43
|
+
expect(resolveLicenseTier()).toBe('pro')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generateKeyPairSync } from 'node:crypto'
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
|
|
8
|
+
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
9
|
+
const PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
|
|
10
|
+
|
|
11
|
+
function makeCtx(body: unknown, sessionValue = 'valid', role: 'admin' | 'editor' = 'admin') {
|
|
12
|
+
const request = new Request('https://cms.example.com/api/setzkasten/migrate/to-multi', {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
body: JSON.stringify(body),
|
|
15
|
+
headers: { 'content-type': 'application/json' },
|
|
16
|
+
})
|
|
17
|
+
const sessionPayload = sessionValue
|
|
18
|
+
? JSON.stringify({ user: { email: 'a@b.com', role }, expiresAt: Date.now() + 60_000 })
|
|
19
|
+
: ''
|
|
20
|
+
return {
|
|
21
|
+
request,
|
|
22
|
+
cookies: {
|
|
23
|
+
get: vi.fn((name: string) =>
|
|
24
|
+
name === 'setzkasten_session' && sessionPayload ? { value: sessionPayload } : undefined,
|
|
25
|
+
),
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.unstubAllEnvs()
|
|
32
|
+
vi.stubEnv('GITHUB_APP_ID', '1')
|
|
33
|
+
vi.stubEnv('GITHUB_APP_INSTALLATION_ID', '111')
|
|
34
|
+
vi.stubEnv('GITHUB_APP_PRIVATE_KEY', PEM)
|
|
35
|
+
vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-PRO-AAAA-BBBB-CCCC')
|
|
36
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
|
|
37
|
+
storage: { kind: 'single', repo: 'acme/site', appId: '1', installationId: '111' },
|
|
38
|
+
}
|
|
39
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = {
|
|
40
|
+
owner: 'acme',
|
|
41
|
+
repo: 'site',
|
|
42
|
+
branch: 'main',
|
|
43
|
+
contentPath: 'content',
|
|
44
|
+
assetsPath: 'public/images',
|
|
45
|
+
projectPrefix: '',
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
vi.restoreAllMocks()
|
|
51
|
+
vi.unstubAllEnvs()
|
|
52
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
|
|
53
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = undefined
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('POST /api/setzkasten/migrate/to-multi', () => {
|
|
57
|
+
it('returns 401 without a session', async () => {
|
|
58
|
+
const { POST } = await import('../migrate-to-multi')
|
|
59
|
+
const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({}, ''))
|
|
60
|
+
|
|
61
|
+
expect(res.status).toBe(401)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns 403 for non-admin users', async () => {
|
|
65
|
+
const { POST } = await import('../migrate-to-multi')
|
|
66
|
+
const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({}, 'valid', 'editor'))
|
|
67
|
+
|
|
68
|
+
expect(res.status).toBe(403)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns 402 for free license tier', async () => {
|
|
72
|
+
vi.stubEnv('SETZKASTEN_LICENSE_KEY', '')
|
|
73
|
+
const { POST } = await import('../migrate-to-multi')
|
|
74
|
+
const res = await (POST as (ctx: unknown) => Promise<Response>)(
|
|
75
|
+
makeCtx({ configRepo: 'acme/cms-config', configInstallationId: '999' }),
|
|
76
|
+
)
|
|
77
|
+
const body = (await res.json()) as { error?: string }
|
|
78
|
+
|
|
79
|
+
expect(res.status).toBe(402)
|
|
80
|
+
expect(body.error).toMatch(/Pro.*Enterprise/i)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('returns 400 when configRepo is missing', async () => {
|
|
84
|
+
const { POST } = await import('../migrate-to-multi')
|
|
85
|
+
const res = await (POST as (ctx: unknown) => Promise<Response>)(
|
|
86
|
+
makeCtx({ configInstallationId: '999' }),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(res.status).toBe(400)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('returns 400 when configInstallationId is missing', async () => {
|
|
93
|
+
const { POST } = await import('../migrate-to-multi')
|
|
94
|
+
const res = await (POST as (ctx: unknown) => Promise<Response>)(
|
|
95
|
+
makeCtx({ configRepo: 'acme/cms-config' }),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
expect(res.status).toBe(400)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('returns 400 when current setup is already multi', async () => {
|
|
102
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
|
|
103
|
+
storage: { kind: 'multi', configRepo: 'a/b', appId: '1', installationId: '1' },
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { POST } = await import('../migrate-to-multi')
|
|
107
|
+
const res = await (POST as (ctx: unknown) => Promise<Response>)(
|
|
108
|
+
makeCtx({ configRepo: 'acme/cms-config', configInstallationId: '999' }),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
expect(res.status).toBe(400)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('copies editors+global and writes a single-entry websites.json', async () => {
|
|
115
|
+
const calls: Array<{ url: string; method?: string; body?: unknown }> = []
|
|
116
|
+
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
|
117
|
+
calls.push({ url, method: init?.method, body: init?.body })
|
|
118
|
+
if (url.includes('/access_tokens')) {
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
json: async () => ({
|
|
122
|
+
token: 'gh_mock',
|
|
123
|
+
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
124
|
+
}),
|
|
125
|
+
} as Response
|
|
126
|
+
}
|
|
127
|
+
// Source reads (website-repo)
|
|
128
|
+
if (url.includes('/repos/acme/site/contents/content/_editors.json')) {
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
json: async () => ({
|
|
132
|
+
content: Buffer.from(JSON.stringify([{ email: 'a@b.com' }])).toString('base64'),
|
|
133
|
+
sha: 'editors-sha',
|
|
134
|
+
}),
|
|
135
|
+
} as Response
|
|
136
|
+
}
|
|
137
|
+
if (url.includes('/repos/acme/site/contents/content/_global_config.json')) {
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
json: async () => ({
|
|
141
|
+
content: Buffer.from(JSON.stringify({ theme: { brandName: 'X' } })).toString('base64'),
|
|
142
|
+
sha: 'gc-sha',
|
|
143
|
+
}),
|
|
144
|
+
} as Response
|
|
145
|
+
}
|
|
146
|
+
// Target writes (config-repo)
|
|
147
|
+
if (url.includes('/repos/acme/cms-config/contents/') && init?.method === 'PUT') {
|
|
148
|
+
return { ok: true, json: async () => ({ content: { sha: 'new' } }) } as Response
|
|
149
|
+
}
|
|
150
|
+
// Target reads (websites.json) — assume not yet present
|
|
151
|
+
if (
|
|
152
|
+
url.includes('/repos/acme/cms-config/contents/websites.json') &&
|
|
153
|
+
(init?.method ?? 'GET') === 'GET'
|
|
154
|
+
) {
|
|
155
|
+
return new Response(null, { status: 404 })
|
|
156
|
+
}
|
|
157
|
+
throw new Error(`unexpected URL: ${url} method=${init?.method}`)
|
|
158
|
+
})
|
|
159
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
160
|
+
|
|
161
|
+
const { POST } = await import('../migrate-to-multi')
|
|
162
|
+
const res = await (POST as (ctx: unknown) => Promise<Response>)(
|
|
163
|
+
makeCtx({ configRepo: 'acme/cms-config', configInstallationId: '999' }),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
expect(res.status).toBe(200)
|
|
167
|
+
const body = (await res.json()) as {
|
|
168
|
+
ok: boolean
|
|
169
|
+
committed: { editors: boolean; globalConfig: boolean; websites: boolean }
|
|
170
|
+
}
|
|
171
|
+
expect(body.ok).toBe(true)
|
|
172
|
+
expect(body.committed.editors).toBe(true)
|
|
173
|
+
expect(body.committed.globalConfig).toBe(true)
|
|
174
|
+
expect(body.committed.websites).toBe(true)
|
|
175
|
+
|
|
176
|
+
// Ensure the websites.json write contained the source-website snapshot
|
|
177
|
+
const websitesPut = calls.find(
|
|
178
|
+
(c) => c.method === 'PUT' && c.url.includes('/repos/acme/cms-config/contents/websites.json'),
|
|
179
|
+
)
|
|
180
|
+
expect(websitesPut).toBeDefined()
|
|
181
|
+
const websitesBody = JSON.parse(String(websitesPut!.body)) as { content: string }
|
|
182
|
+
const websitesPayload = JSON.parse(
|
|
183
|
+
Buffer.from(websitesBody.content, 'base64').toString('utf-8'),
|
|
184
|
+
)
|
|
185
|
+
expect(websitesPayload.websites).toHaveLength(1)
|
|
186
|
+
expect(websitesPayload.websites[0].repo).toBe('acme/site')
|
|
187
|
+
expect(websitesPayload.websites[0].githubApp.installationId).toBe('111')
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
const TARGET = {
|
|
8
|
+
owner: 'acme',
|
|
9
|
+
repo: 'site',
|
|
10
|
+
branch: 'main',
|
|
11
|
+
contentPath: 'content',
|
|
12
|
+
token: 'gh-token',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.unstubAllEnvs()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.restoreAllMocks()
|
|
21
|
+
vi.unstubAllEnvs()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function fetchSequence(steps: Array<(url: string, init?: RequestInit) => Response | Promise<Response>>) {
|
|
25
|
+
let i = 0
|
|
26
|
+
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
|
27
|
+
const handler = steps[Math.min(i++, steps.length - 1)]
|
|
28
|
+
if (!handler) throw new Error('fetchSequence: empty step list')
|
|
29
|
+
return handler(url, init)
|
|
30
|
+
})
|
|
31
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
32
|
+
return fetchMock
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('readPagesMeta', () => {
|
|
36
|
+
it('returns an empty meta when GitHub responds 404', async () => {
|
|
37
|
+
fetchSequence([() => new Response(null, { status: 404 })])
|
|
38
|
+
|
|
39
|
+
const { readPagesMeta } = await import('../_pages-meta-store')
|
|
40
|
+
const result = await readPagesMeta(TARGET)
|
|
41
|
+
|
|
42
|
+
expect(result.ok).toBe(true)
|
|
43
|
+
if (result.ok) {
|
|
44
|
+
expect(result.value.meta.pages).toEqual({})
|
|
45
|
+
expect(result.value.sha).toBeNull()
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns the parsed meta + sha when the file exists', async () => {
|
|
50
|
+
const meta = { version: 1, pages: { index: { lastModified: 5 } } }
|
|
51
|
+
fetchSequence([
|
|
52
|
+
() =>
|
|
53
|
+
new Response(
|
|
54
|
+
JSON.stringify({ content: Buffer.from(JSON.stringify(meta)).toString('base64'), sha: 'abc' }),
|
|
55
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
56
|
+
),
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
const { readPagesMeta } = await import('../_pages-meta-store')
|
|
60
|
+
const result = await readPagesMeta(TARGET)
|
|
61
|
+
|
|
62
|
+
expect(result.ok).toBe(true)
|
|
63
|
+
if (result.ok) {
|
|
64
|
+
expect(result.value.meta.pages.index?.lastModified).toBe(5)
|
|
65
|
+
expect(result.value.sha).toBe('abc')
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('falls through to network error on non-404 failures', async () => {
|
|
70
|
+
fetchSequence([() => new Response('boom', { status: 500 })])
|
|
71
|
+
|
|
72
|
+
const { readPagesMeta } = await import('../_pages-meta-store')
|
|
73
|
+
const result = await readPagesMeta(TARGET)
|
|
74
|
+
|
|
75
|
+
expect(result.ok).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('recordPageEdit', () => {
|
|
80
|
+
it('reads, sets the timestamp, writes back — with the correct sha', async () => {
|
|
81
|
+
const calls: { url: string; method?: string; body?: unknown }[] = []
|
|
82
|
+
const existingMeta = { version: 1, pages: { index: { lastModified: 1 } } }
|
|
83
|
+
fetchSequence([
|
|
84
|
+
// 1) GET existing meta
|
|
85
|
+
(url, init) => {
|
|
86
|
+
calls.push({ url, method: init?.method, body: init?.body })
|
|
87
|
+
return new Response(
|
|
88
|
+
JSON.stringify({
|
|
89
|
+
content: Buffer.from(JSON.stringify(existingMeta)).toString('base64'),
|
|
90
|
+
sha: 'old-sha',
|
|
91
|
+
}),
|
|
92
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
93
|
+
)
|
|
94
|
+
},
|
|
95
|
+
// 2) PUT updated meta
|
|
96
|
+
(url, init) => {
|
|
97
|
+
calls.push({ url, method: init?.method, body: init?.body })
|
|
98
|
+
return new Response(JSON.stringify({ content: { sha: 'new-sha' } }), {
|
|
99
|
+
status: 200,
|
|
100
|
+
headers: { 'content-type': 'application/json' },
|
|
101
|
+
})
|
|
102
|
+
},
|
|
103
|
+
])
|
|
104
|
+
|
|
105
|
+
const { recordPageEdit } = await import('../_pages-meta-store')
|
|
106
|
+
const result = await recordPageEdit(TARGET, 'about', 99)
|
|
107
|
+
|
|
108
|
+
expect(result.ok).toBe(true)
|
|
109
|
+
expect(calls).toHaveLength(2)
|
|
110
|
+
expect(calls[1]?.method).toBe('PUT')
|
|
111
|
+
const writtenBody = JSON.parse(String(calls[1]?.body)) as { content: string; sha?: string }
|
|
112
|
+
expect(writtenBody.sha).toBe('old-sha')
|
|
113
|
+
const writtenMeta = JSON.parse(Buffer.from(writtenBody.content, 'base64').toString('utf-8'))
|
|
114
|
+
expect(writtenMeta.pages.index.lastModified).toBe(1)
|
|
115
|
+
expect(writtenMeta.pages.about.lastModified).toBe(99)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('initialises the file when it does not exist (no sha sent)', async () => {
|
|
119
|
+
const calls: { url: string; method?: string; body?: unknown }[] = []
|
|
120
|
+
fetchSequence([
|
|
121
|
+
(url, init) => {
|
|
122
|
+
calls.push({ url, method: init?.method, body: init?.body })
|
|
123
|
+
return new Response(null, { status: 404 })
|
|
124
|
+
},
|
|
125
|
+
(url, init) => {
|
|
126
|
+
calls.push({ url, method: init?.method, body: init?.body })
|
|
127
|
+
return new Response(JSON.stringify({ content: { sha: 'first' } }), {
|
|
128
|
+
status: 200,
|
|
129
|
+
headers: { 'content-type': 'application/json' },
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
])
|
|
133
|
+
|
|
134
|
+
const { recordPageEdit } = await import('../_pages-meta-store')
|
|
135
|
+
const result = await recordPageEdit(TARGET, 'about', 42)
|
|
136
|
+
|
|
137
|
+
expect(result.ok).toBe(true)
|
|
138
|
+
const putBody = JSON.parse(String(calls[1]?.body)) as { sha?: string }
|
|
139
|
+
expect(putBody.sha).toBeUndefined()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('retries once on a 409 conflict, succeeds on the second write', async () => {
|
|
143
|
+
const empty = { version: 1, pages: {} }
|
|
144
|
+
fetchSequence([
|
|
145
|
+
() =>
|
|
146
|
+
new Response(
|
|
147
|
+
JSON.stringify({
|
|
148
|
+
content: Buffer.from(JSON.stringify(empty)).toString('base64'),
|
|
149
|
+
sha: 'first',
|
|
150
|
+
}),
|
|
151
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
152
|
+
),
|
|
153
|
+
// First PUT → 409
|
|
154
|
+
() => new Response('conflict', { status: 409 }),
|
|
155
|
+
// Re-read with newer sha
|
|
156
|
+
() =>
|
|
157
|
+
new Response(
|
|
158
|
+
JSON.stringify({
|
|
159
|
+
content: Buffer.from(JSON.stringify({ version: 1, pages: { x: { lastModified: 2 } } })).toString(
|
|
160
|
+
'base64',
|
|
161
|
+
),
|
|
162
|
+
sha: 'second',
|
|
163
|
+
}),
|
|
164
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
165
|
+
),
|
|
166
|
+
// Second PUT → ok
|
|
167
|
+
() =>
|
|
168
|
+
new Response(JSON.stringify({ content: { sha: 'third' } }), {
|
|
169
|
+
status: 200,
|
|
170
|
+
headers: { 'content-type': 'application/json' },
|
|
171
|
+
}),
|
|
172
|
+
])
|
|
173
|
+
|
|
174
|
+
const { recordPageEdit } = await import('../_pages-meta-store')
|
|
175
|
+
const result = await recordPageEdit(TARGET, 'about', 7)
|
|
176
|
+
|
|
177
|
+
expect(result.ok).toBe(true)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the pages API route resolver.
|
|
3
|
+
*
|
|
4
|
+
* The pages endpoint must work in API-route context (serverless) where
|
|
5
|
+
* `page-ssr` injectScript does NOT run and globalThis.__SETZKASTEN_PAGES__
|
|
6
|
+
* is undefined. The fix is a Vite build-time define (__SETZKASTEN_PAGES__)
|
|
7
|
+
* that is always available in compiled modules.
|
|
8
|
+
*
|
|
9
|
+
* In the test environment the Vite define is not applied, so we test the
|
|
10
|
+
* globalThis fallback path — which is what we'd use in a cold-start serverless
|
|
11
|
+
* invocation before the define was introduced.
|
|
12
|
+
*
|
|
13
|
+
* @vitest-environment node
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
17
|
+
import { resolvePages } from '../pages'
|
|
18
|
+
|
|
19
|
+
interface PageInfo {
|
|
20
|
+
path: string
|
|
21
|
+
pageKey: string
|
|
22
|
+
label: string
|
|
23
|
+
hasConfig: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SAMPLE_PAGES: PageInfo[] = [
|
|
27
|
+
{ path: '/', pageKey: 'index', label: 'Startseite', hasConfig: true },
|
|
28
|
+
{ path: '/impressum', pageKey: 'impressum', label: 'Impressum', hasConfig: false },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
delete (globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
delete (globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('resolvePages', () => {
|
|
40
|
+
it('returns empty array when globalThis.__SETZKASTEN_PAGES__ is not set', () => {
|
|
41
|
+
expect(resolvePages()).toEqual([])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('reads pages from globalThis.__SETZKASTEN_PAGES__', () => {
|
|
45
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ = SAMPLE_PAGES
|
|
46
|
+
expect(resolvePages()).toEqual(SAMPLE_PAGES)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns all page fields (path, pageKey, label, hasConfig)', () => {
|
|
50
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ = SAMPLE_PAGES
|
|
51
|
+
const result = resolvePages()
|
|
52
|
+
expect(result[0]).toHaveProperty('path', '/')
|
|
53
|
+
expect(result[0]).toHaveProperty('pageKey', 'index')
|
|
54
|
+
expect(result[0]).toHaveProperty('label', 'Startseite')
|
|
55
|
+
expect(result[0]).toHaveProperty('hasConfig', true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns empty array when globalThis.__SETZKASTEN_PAGES__ is explicitly empty', () => {
|
|
59
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ = []
|
|
60
|
+
expect(resolvePages()).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('handles multiple pages including nested paths', () => {
|
|
64
|
+
const pages: PageInfo[] = [
|
|
65
|
+
{ path: '/', pageKey: 'index', label: 'Startseite', hasConfig: true },
|
|
66
|
+
{ path: '/docs/architecture', pageKey: 'docs/architecture', label: 'Architecture', hasConfig: false },
|
|
67
|
+
]
|
|
68
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ = pages
|
|
69
|
+
expect(resolvePages()).toHaveLength(2)
|
|
70
|
+
expect(resolvePages()[1]!.pageKey).toBe('docs/architecture')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route-Registry-Konsistenztest
|
|
3
|
+
*
|
|
4
|
+
* Jede öffentliche Route-Datei in packages/astro-admin/src/api-routes/
|
|
5
|
+
* muss an drei Stellen registriert sein:
|
|
6
|
+
* 1. Als Export in packages/astro-admin/package.json
|
|
7
|
+
* 2. Als injectRoute-Entrypoint in packages/astro/src/integration.ts
|
|
8
|
+
*
|
|
9
|
+
* Ausnahmen (kein Export, kein injectRoute nötig):
|
|
10
|
+
* - Dateien mit `_`-Prefix → interne Helpers
|
|
11
|
+
* - Dateien mit `-helpers` → interne Helpers
|
|
12
|
+
* - Dateien mit `-management` → interne Helpers (nur von anderen Routes importiert)
|
|
13
|
+
*
|
|
14
|
+
* Dieser Test verhindert, dass neue Route-Dateien vergessen werden —
|
|
15
|
+
* ein Fehler, der erst beim Produktions-Build auffällt (ENOENT).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from 'vitest'
|
|
19
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { resolve, dirname } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
24
|
+
// Test sits at src/api-routes/__tests__, so go up three levels
|
|
25
|
+
const ADMIN_ROOT = resolve(__dirname, '../../../') // packages/astro-admin
|
|
26
|
+
const ASTRO_ROOT = resolve(__dirname, '../../../../astro') // packages/astro
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Load sources
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const routeFiles = readdirSync(resolve(ADMIN_ROOT, 'src/api-routes')).filter(
|
|
33
|
+
f => f.endsWith('.ts') && !f.endsWith('.test.ts'),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const packageJson = JSON.parse(
|
|
37
|
+
readFileSync(resolve(ADMIN_ROOT, 'package.json'), 'utf-8'),
|
|
38
|
+
) as { exports: Record<string, string> }
|
|
39
|
+
|
|
40
|
+
const integrationSrc = readFileSync(
|
|
41
|
+
resolve(ASTRO_ROOT, 'src/integration.ts'),
|
|
42
|
+
'utf-8',
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Helpers
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/** Files that are internal — no export or injectRoute needed */
|
|
50
|
+
function isInternal(filename: string): boolean {
|
|
51
|
+
const base = filename.replace(/\.ts$/, '')
|
|
52
|
+
return (
|
|
53
|
+
base.startsWith('_') ||
|
|
54
|
+
base.endsWith('-helpers') ||
|
|
55
|
+
base.endsWith('-management')
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Export key as used in package.json, e.g. "catalog-list" → "./catalog" special-cased */
|
|
60
|
+
function exportKey(filename: string): string {
|
|
61
|
+
const base = filename.replace(/\.ts$/, '')
|
|
62
|
+
// Special case: catalog-list is exported as "./catalog"
|
|
63
|
+
if (base === 'catalog-list') return './catalog'
|
|
64
|
+
return `./${base}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function entrypoint(filename: string): string {
|
|
68
|
+
const base = filename.replace(/\.ts$/, '')
|
|
69
|
+
if (base === 'catalog-list') return '@setzkasten-cms/astro-admin/catalog'
|
|
70
|
+
return `@setzkasten-cms/astro-admin/${base}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Tests
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
const publicRoutes = routeFiles.filter(f => !isInternal(f))
|
|
78
|
+
|
|
79
|
+
describe('Route-Registry-Konsistenz', () => {
|
|
80
|
+
describe('package.json exports', () => {
|
|
81
|
+
for (const file of publicRoutes) {
|
|
82
|
+
it(`${file} ist in package.json exports registriert`, () => {
|
|
83
|
+
const key = exportKey(file)
|
|
84
|
+
expect(
|
|
85
|
+
packageJson.exports,
|
|
86
|
+
`Fehlender Export: "${key}" in packages/astro-admin/package.json`,
|
|
87
|
+
).toHaveProperty(key)
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('integration.ts injectRoute', () => {
|
|
93
|
+
for (const file of publicRoutes) {
|
|
94
|
+
it(`${file} ist als injectRoute-Entrypoint in integration.ts registriert`, () => {
|
|
95
|
+
const ep = entrypoint(file)
|
|
96
|
+
expect(
|
|
97
|
+
integrationSrc,
|
|
98
|
+
`Fehlender injectRoute-Eintrag für '${ep}' in packages/astro/src/integration.ts`,
|
|
99
|
+
).toContain(`'${ep}'`)
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('Keine verwaisten Exports', () => {
|
|
105
|
+
it('alle package.json-Exports (astro-admin routes) haben eine entsprechende Datei', () => {
|
|
106
|
+
const routeExports = Object.entries(packageJson.exports)
|
|
107
|
+
.filter(([, v]) => v.startsWith('./src/api-routes/'))
|
|
108
|
+
.map(([k]) => k)
|
|
109
|
+
|
|
110
|
+
const knownKeys = new Set(publicRoutes.map(exportKey))
|
|
111
|
+
|
|
112
|
+
for (const key of routeExports) {
|
|
113
|
+
expect(
|
|
114
|
+
knownKeys.has(key),
|
|
115
|
+
`Export "${key}" in package.json hat keine Route-Datei mehr`,
|
|
116
|
+
).toBe(true)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
})
|