@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.
Files changed (68) hide show
  1. package/package.json +16 -6
  2. package/src/admin-page.astro +1 -1
  3. package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
  4. package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
  5. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  6. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  7. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  8. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  9. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  10. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
  11. package/src/api-routes/__tests__/route-registry.test.ts +120 -0
  12. package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
  13. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +145 -0
  14. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
  15. package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
  16. package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
  17. package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
  18. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  19. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  20. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  21. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  22. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  23. package/src/api-routes/_auth-guard.ts +134 -13
  24. package/src/api-routes/_github-token.ts +64 -0
  25. package/src/api-routes/_license-tier.ts +25 -0
  26. package/src/api-routes/_pages-meta-store.ts +134 -0
  27. package/src/api-routes/_session-cookie.ts +42 -0
  28. package/src/api-routes/_storage-config.ts +64 -4
  29. package/src/api-routes/_vercel-origin.ts +22 -0
  30. package/src/api-routes/_website-resolver.ts +243 -0
  31. package/src/api-routes/_websites-store.ts +120 -0
  32. package/src/api-routes/asset-proxy.ts +6 -4
  33. package/src/api-routes/auth-callback.ts +6 -7
  34. package/src/api-routes/auth-logout.ts +5 -1
  35. package/src/api-routes/auth-setzkasten-login.ts +21 -10
  36. package/src/api-routes/catalog-add.ts +9 -5
  37. package/src/api-routes/catalog-export.ts +8 -4
  38. package/src/api-routes/config.ts +12 -5
  39. package/src/api-routes/editors.ts +79 -10
  40. package/src/api-routes/github-proxy.ts +5 -5
  41. package/src/api-routes/global-config.ts +23 -6
  42. package/src/api-routes/init-add-section.ts +13 -5
  43. package/src/api-routes/init-apply.ts +5 -3
  44. package/src/api-routes/init-migrate.ts +7 -5
  45. package/src/api-routes/init-scan-page.ts +26 -6
  46. package/src/api-routes/init-scan.ts +5 -3
  47. package/src/api-routes/migrate-to-multi.ts +255 -0
  48. package/src/api-routes/pages.ts +118 -4
  49. package/src/api-routes/section-add.ts +15 -5
  50. package/src/api-routes/section-commit-pending.ts +18 -5
  51. package/src/api-routes/section-delete.ts +15 -5
  52. package/src/api-routes/section-duplicate.ts +15 -5
  53. package/src/api-routes/section-prepare-copy.ts +15 -4
  54. package/src/api-routes/section-prepare.ts +9 -5
  55. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  56. package/src/api-routes/setup-github-app-branches.ts +63 -0
  57. package/src/api-routes/setup-github-app-callback.ts +53 -0
  58. package/src/api-routes/setup-github-app-installed.ts +44 -0
  59. package/src/api-routes/setup-github-app-repos.ts +46 -0
  60. package/src/api-routes/setup-github-app.ts +58 -0
  61. package/src/api-routes/updater-register.ts +6 -23
  62. package/src/api-routes/updater-transfer.ts +1 -12
  63. package/src/api-routes/websites-add.ts +113 -0
  64. package/src/api-routes/websites-list.ts +40 -0
  65. package/src/api-routes/websites-remove.ts +74 -0
  66. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  67. package/src/init/template-patcher-v2.ts +33 -0
  68. package/LICENSE +0 -37
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@setzkasten-cms/astro-admin",
3
- "version": "0.8.0",
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",
@@ -51,6 +51,16 @@
51
51
  "./updater-transfer": "./src/api-routes/updater-transfer.ts",
52
52
  "./updater-unbind": "./src/api-routes/updater-unbind.ts",
53
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",
54
64
  "./admin-page": "./src/admin-page.astro"
55
65
  },
56
66
  "devDependencies": {
@@ -58,11 +68,11 @@
58
68
  },
59
69
  "dependencies": {
60
70
  "@astrojs/compiler": "^3.0.0",
61
- "@setzkasten-cms/auth": "0.8.0",
62
- "@setzkasten-cms/catalog": "0.8.0",
63
- "@setzkasten-cms/core": "0.8.0",
64
- "@setzkasten-cms/github-adapter": "0.8.0",
65
- "@setzkasten-cms/ui": "0.8.0"
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"
66
76
  },
67
77
  "peerDependencies": {
68
78
  "astro": "^5.0.0",
@@ -81,7 +81,7 @@
81
81
  // providers is always derived from injected flags (license-aware),
82
82
  // so we override auth.providers regardless of what userConfig says.
83
83
  const skConfig = {
84
- storage: { kind: 'github' as const },
84
+ storage: { kind: 'local' as const },
85
85
  theme: {},
86
86
  products: {},
87
87
  collections: {},
@@ -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,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
+ })
@@ -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
+ })