@setzkasten-cms/astro-admin 1.1.0 → 1.4.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 +13 -6
- package/src/api-routes/__tests__/feature-gate.test.ts +60 -0
- package/src/api-routes/__tests__/history-rollback.test.ts +196 -0
- package/src/api-routes/__tests__/history.test.ts +168 -0
- package/src/api-routes/__tests__/setup-github-app-callback.test.ts +7 -0
- package/src/api-routes/__tests__/webhook-signing.test.ts +39 -0
- package/src/api-routes/__tests__/webhooks.test.ts +219 -0
- package/src/api-routes/_feature-gate.ts +39 -0
- package/src/api-routes/_role-resolver.ts +60 -0
- package/src/api-routes/_storage-config.ts +15 -2
- package/src/api-routes/_webhook-dispatcher.ts +120 -0
- package/src/api-routes/_webhook-signing.ts +13 -0
- package/src/api-routes/_webhook-status-store.ts +31 -0
- package/src/api-routes/auth-callback.ts +2 -0
- package/src/api-routes/auth-setzkasten-login.ts +16 -1
- package/src/api-routes/editors.ts +15 -0
- package/src/api-routes/history-rollback.ts +144 -0
- package/src/api-routes/history-version.ts +57 -0
- package/src/api-routes/history.ts +119 -0
- package/src/api-routes/icons-local.ts +169 -0
- package/src/api-routes/section-commit-pending.ts +108 -9
- package/src/api-routes/section-delete.ts +14 -0
- package/src/api-routes/setup-github-app-callback.ts +20 -2
- package/src/api-routes/updater-register.ts +31 -2
- package/src/api-routes/webhooks-status.ts +17 -0
- package/src/api-routes/webhooks-test.ts +134 -0
- package/src/api-routes/webhooks.ts +163 -0
- package/src/init/__tests__/patcher-edge-cases.test.ts +34 -1
- package/src/init/template-patcher-v2.ts +9 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@setzkasten-cms/astro-admin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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",
|
|
@@ -37,9 +37,16 @@
|
|
|
37
37
|
"./init-add-section": "./src/api-routes/init-add-section.ts",
|
|
38
38
|
"./init-migrate": "./src/api-routes/init-migrate.ts",
|
|
39
39
|
"./deploy-hook": "./src/api-routes/deploy-hook.ts",
|
|
40
|
+
"./icons-local": "./src/api-routes/icons-local.ts",
|
|
40
41
|
"./catalog": "./src/api-routes/catalog-list.ts",
|
|
41
42
|
"./catalog-add": "./src/api-routes/catalog-add.ts",
|
|
42
43
|
"./catalog-export": "./src/api-routes/catalog-export.ts",
|
|
44
|
+
"./history": "./src/api-routes/history.ts",
|
|
45
|
+
"./history-version": "./src/api-routes/history-version.ts",
|
|
46
|
+
"./history-rollback": "./src/api-routes/history-rollback.ts",
|
|
47
|
+
"./webhooks": "./src/api-routes/webhooks.ts",
|
|
48
|
+
"./webhooks-test": "./src/api-routes/webhooks-test.ts",
|
|
49
|
+
"./webhooks-status": "./src/api-routes/webhooks-status.ts",
|
|
43
50
|
"./section-add": "./src/api-routes/section-add.ts",
|
|
44
51
|
"./section-prepare": "./src/api-routes/section-prepare.ts",
|
|
45
52
|
"./section-prepare-copy": "./src/api-routes/section-prepare-copy.ts",
|
|
@@ -68,11 +75,11 @@
|
|
|
68
75
|
},
|
|
69
76
|
"dependencies": {
|
|
70
77
|
"@astrojs/compiler": "^3.0.0",
|
|
71
|
-
"@setzkasten-cms/auth": "1.
|
|
72
|
-
"@setzkasten-cms/catalog": "1.
|
|
73
|
-
"@setzkasten-cms/
|
|
74
|
-
"@setzkasten-cms/
|
|
75
|
-
"@setzkasten-cms/
|
|
78
|
+
"@setzkasten-cms/auth": "1.4.0",
|
|
79
|
+
"@setzkasten-cms/catalog": "1.4.0",
|
|
80
|
+
"@setzkasten-cms/core": "1.4.0",
|
|
81
|
+
"@setzkasten-cms/ui": "1.4.0",
|
|
82
|
+
"@setzkasten-cms/github-adapter": "1.4.0"
|
|
76
83
|
},
|
|
77
84
|
"peerDependencies": {
|
|
78
85
|
"astro": "^5.0.0",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { gateFeature } from '../_feature-gate'
|
|
3
|
+
|
|
4
|
+
const ORIGINAL_KEY = process.env.SETZKASTEN_LICENSE_KEY
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
if (ORIGINAL_KEY === undefined) delete process.env.SETZKASTEN_LICENSE_KEY
|
|
8
|
+
else process.env.SETZKASTEN_LICENSE_KEY = ORIGINAL_KEY
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('gateFeature', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
delete process.env.SETZKASTEN_LICENSE_KEY
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns null (allow) for an unknown feature key at any tier', () => {
|
|
17
|
+
expect(gateFeature('something-not-yet-declared')).toBeNull()
|
|
18
|
+
process.env.SETZKASTEN_LICENSE_KEY = 'SK-PRO-AAAA-BBBB-CCCC'
|
|
19
|
+
expect(gateFeature('something-not-yet-declared')).toBeNull()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('blocks editors at free tier with 403 + JSON body', async () => {
|
|
23
|
+
const res = gateFeature('editors')
|
|
24
|
+
expect(res).not.toBeNull()
|
|
25
|
+
expect(res!.status).toBe(403)
|
|
26
|
+
expect(res!.headers.get('Content-Type')).toContain('application/json')
|
|
27
|
+
const body = await res!.json()
|
|
28
|
+
expect(body).toMatchObject({
|
|
29
|
+
code: 'feature-locked',
|
|
30
|
+
feature: 'editors',
|
|
31
|
+
requiredTier: 'pro',
|
|
32
|
+
currentTier: 'free',
|
|
33
|
+
})
|
|
34
|
+
expect(body.error).toBeTruthy()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('allows editors at pro tier (returns null)', () => {
|
|
38
|
+
process.env.SETZKASTEN_LICENSE_KEY = 'SK-PRO-AAAA-BBBB-CCCC'
|
|
39
|
+
expect(gateFeature('editors')).toBeNull()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('allows editors at enterprise tier (returns null)', () => {
|
|
43
|
+
process.env.SETZKASTEN_LICENSE_KEY = 'SK-ENT-AAAA-BBBB-CCCC'
|
|
44
|
+
expect(gateFeature('editors')).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('blocks google-auth at free tier', async () => {
|
|
48
|
+
const res = gateFeature('google-auth')
|
|
49
|
+
expect(res).not.toBeNull()
|
|
50
|
+
expect(res!.status).toBe(403)
|
|
51
|
+
const body = await res!.json()
|
|
52
|
+
expect(body.feature).toBe('google-auth')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('blocks webhooks at free tier (reserved key)', async () => {
|
|
56
|
+
const res = gateFeature('webhooks')
|
|
57
|
+
expect(res).not.toBeNull()
|
|
58
|
+
expect(res!.status).toBe(403)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
const ADMIN_SESSION = JSON.stringify({
|
|
12
|
+
user: { id: 'u1', email: 'admin@example.com', role: 'admin', provider: 'github' },
|
|
13
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const EDITOR_SESSION = JSON.stringify({
|
|
17
|
+
user: { id: 'u2', email: 'editor@example.com', role: 'editor', provider: 'google' },
|
|
18
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
function makeCtx(body: unknown, sessionValue: string | null = ADMIN_SESSION) {
|
|
22
|
+
const request = new Request('https://cms.example.com/api/setzkasten/history/rollback', {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
body: JSON.stringify(body),
|
|
25
|
+
headers: { 'content-type': 'application/json' },
|
|
26
|
+
})
|
|
27
|
+
return {
|
|
28
|
+
request,
|
|
29
|
+
cookies: {
|
|
30
|
+
get: vi.fn((name: string) =>
|
|
31
|
+
name === 'setzkasten_session' && sessionValue ? { value: sessionValue } : undefined,
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.unstubAllEnvs()
|
|
39
|
+
vi.stubEnv('GITHUB_APP_ID', '1')
|
|
40
|
+
vi.stubEnv('GITHUB_APP_INSTALLATION_ID', '111')
|
|
41
|
+
vi.stubEnv('GITHUB_APP_PRIVATE_KEY', PEM)
|
|
42
|
+
vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-PRO-AAAAAAAA-BBBBBBBB-CCCCCCCC')
|
|
43
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = {
|
|
44
|
+
kind: 'github-app',
|
|
45
|
+
repo: 'acme/site',
|
|
46
|
+
branch: 'main',
|
|
47
|
+
appId: '1',
|
|
48
|
+
installationId: '111',
|
|
49
|
+
}
|
|
50
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
|
|
51
|
+
storage: {
|
|
52
|
+
kind: 'github-app',
|
|
53
|
+
repo: 'acme/site',
|
|
54
|
+
branch: 'main',
|
|
55
|
+
appId: '1',
|
|
56
|
+
installationId: '111',
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
vi.restoreAllMocks()
|
|
63
|
+
vi.unstubAllEnvs()
|
|
64
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = undefined
|
|
65
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const ROLLBACK_PATH = 'content/sections/hero.json'
|
|
69
|
+
const TARGET_SHA = 'b'.repeat(40)
|
|
70
|
+
const HEAD_SHA_FILE = 'c'.repeat(40)
|
|
71
|
+
|
|
72
|
+
describe('POST /api/setzkasten/history/rollback', () => {
|
|
73
|
+
it('returns 401 without a session', async () => {
|
|
74
|
+
const { POST } = await import('../history-rollback')
|
|
75
|
+
const res = await (POST as (ctx: unknown) => Promise<Response>)(
|
|
76
|
+
makeCtx({ path: ROLLBACK_PATH, sha: TARGET_SHA }, null),
|
|
77
|
+
)
|
|
78
|
+
expect(res.status).toBe(401)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('returns 403 for editor session', async () => {
|
|
82
|
+
const { POST } = await import('../history-rollback')
|
|
83
|
+
const res = await (POST as (ctx: unknown) => Promise<Response>)(
|
|
84
|
+
makeCtx({ path: ROLLBACK_PATH, sha: TARGET_SHA }, EDITOR_SESSION),
|
|
85
|
+
)
|
|
86
|
+
expect(res.status).toBe(403)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('returns 400 when path or sha missing', async () => {
|
|
90
|
+
const { POST } = await import('../history-rollback')
|
|
91
|
+
const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({ path: ROLLBACK_PATH }))
|
|
92
|
+
expect(res.status).toBe(400)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('writes a new commit with the historical content', async () => {
|
|
96
|
+
const calls: string[] = []
|
|
97
|
+
const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
98
|
+
const u = url instanceof URL ? url : new URL(url)
|
|
99
|
+
calls.push(`${init?.method ?? 'GET'} ${u.pathname}${u.search}`)
|
|
100
|
+
if (u.pathname.endsWith('/access_tokens')) {
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
json: async () => ({
|
|
104
|
+
token: 'gh_mock',
|
|
105
|
+
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
106
|
+
}),
|
|
107
|
+
} as Response
|
|
108
|
+
}
|
|
109
|
+
// Read at target sha
|
|
110
|
+
if (u.search.includes(`ref=${TARGET_SHA}`)) {
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
status: 200,
|
|
114
|
+
json: async () => ({
|
|
115
|
+
content: Buffer.from('{"heading":"old"}').toString('base64'),
|
|
116
|
+
encoding: 'base64',
|
|
117
|
+
sha: 'old-blob',
|
|
118
|
+
}),
|
|
119
|
+
} as Response
|
|
120
|
+
}
|
|
121
|
+
// Read HEAD blob sha
|
|
122
|
+
if (u.search.includes('ref=main') && (init?.method ?? 'GET') === 'GET') {
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
status: 200,
|
|
126
|
+
json: async () => ({ sha: HEAD_SHA_FILE }),
|
|
127
|
+
} as Response
|
|
128
|
+
}
|
|
129
|
+
// PUT new commit
|
|
130
|
+
if (init?.method === 'PUT' && u.pathname.includes('/contents/')) {
|
|
131
|
+
return {
|
|
132
|
+
ok: true,
|
|
133
|
+
status: 200,
|
|
134
|
+
json: async () => ({ commit: { sha: 'd'.repeat(40) } }),
|
|
135
|
+
} as Response
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`unexpected URL: ${u.toString()}`)
|
|
138
|
+
})
|
|
139
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
140
|
+
|
|
141
|
+
const { POST } = await import('../history-rollback')
|
|
142
|
+
const res = await (POST as (ctx: unknown) => Promise<Response>)(
|
|
143
|
+
makeCtx({ path: ROLLBACK_PATH, sha: TARGET_SHA }),
|
|
144
|
+
)
|
|
145
|
+
expect(res.status).toBe(200)
|
|
146
|
+
const body = await res.json()
|
|
147
|
+
expect(body.ok).toBe(true)
|
|
148
|
+
expect(body.commitSha).toBe('d'.repeat(40))
|
|
149
|
+
expect(calls.some((c) => c.startsWith('PUT'))).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('returns 409 when expectedHeadSha does not match current HEAD', async () => {
|
|
153
|
+
const fetchMock = vi.fn(async (url: string | URL) => {
|
|
154
|
+
const u = url instanceof URL ? url : new URL(url)
|
|
155
|
+
if (u.pathname.endsWith('/access_tokens')) {
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
json: async () => ({
|
|
159
|
+
token: 'gh_mock',
|
|
160
|
+
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
161
|
+
}),
|
|
162
|
+
} as Response
|
|
163
|
+
}
|
|
164
|
+
if (u.search.includes(`ref=${TARGET_SHA}`)) {
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
status: 200,
|
|
168
|
+
json: async () => ({
|
|
169
|
+
content: Buffer.from('{"heading":"old"}').toString('base64'),
|
|
170
|
+
encoding: 'base64',
|
|
171
|
+
sha: 'old-blob',
|
|
172
|
+
}),
|
|
173
|
+
} as Response
|
|
174
|
+
}
|
|
175
|
+
// HEAD has SHA "moved-on" but client expected "expected"
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
status: 200,
|
|
179
|
+
json: async () => ({ sha: 'moved-on-since-page-load' }),
|
|
180
|
+
} as Response
|
|
181
|
+
})
|
|
182
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
183
|
+
|
|
184
|
+
const { POST } = await import('../history-rollback')
|
|
185
|
+
const res = await (POST as (ctx: unknown) => Promise<Response>)(
|
|
186
|
+
makeCtx({
|
|
187
|
+
path: ROLLBACK_PATH,
|
|
188
|
+
sha: TARGET_SHA,
|
|
189
|
+
expectedHeadSha: 'something-else-entirely',
|
|
190
|
+
}),
|
|
191
|
+
)
|
|
192
|
+
expect(res.status).toBe(409)
|
|
193
|
+
const body = await res.json()
|
|
194
|
+
expect(body.code).toBe('head-moved')
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
const ADMIN_SESSION = JSON.stringify({
|
|
12
|
+
user: { id: 'u1', email: 'admin@example.com', role: 'admin', provider: 'github' },
|
|
13
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const EDITOR_SESSION = JSON.stringify({
|
|
17
|
+
user: { id: 'u2', email: 'editor@example.com', role: 'editor', provider: 'google' },
|
|
18
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
function makeCtx(searchParams: Record<string, string>, sessionValue: string | null = ADMIN_SESSION) {
|
|
22
|
+
const url = new URL('https://cms.example.com/api/setzkasten/history')
|
|
23
|
+
for (const [k, v] of Object.entries(searchParams)) url.searchParams.set(k, v)
|
|
24
|
+
const request = new Request(url, { method: 'GET' })
|
|
25
|
+
return {
|
|
26
|
+
request,
|
|
27
|
+
url,
|
|
28
|
+
cookies: {
|
|
29
|
+
get: vi.fn((name: string) =>
|
|
30
|
+
name === 'setzkasten_session' && sessionValue ? { value: sessionValue } : undefined,
|
|
31
|
+
),
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.unstubAllEnvs()
|
|
38
|
+
vi.stubEnv('GITHUB_APP_ID', '1')
|
|
39
|
+
vi.stubEnv('GITHUB_APP_INSTALLATION_ID', '111')
|
|
40
|
+
vi.stubEnv('GITHUB_APP_PRIVATE_KEY', PEM)
|
|
41
|
+
vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-PRO-AAAAAAAA-BBBBBBBB-CCCCCCCC')
|
|
42
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = {
|
|
43
|
+
kind: 'github-app',
|
|
44
|
+
repo: 'acme/site',
|
|
45
|
+
branch: 'main',
|
|
46
|
+
appId: '1',
|
|
47
|
+
installationId: '111',
|
|
48
|
+
}
|
|
49
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
|
|
50
|
+
storage: {
|
|
51
|
+
kind: 'github-app',
|
|
52
|
+
repo: 'acme/site',
|
|
53
|
+
branch: 'main',
|
|
54
|
+
appId: '1',
|
|
55
|
+
installationId: '111',
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
// Reset history cache between tests so cachedFetch doesn't pollute state.
|
|
59
|
+
return import('../_github-cache').then((m) => {
|
|
60
|
+
// We don't know the keys, but the cache is keyed by route+args; tests
|
|
61
|
+
// pick fresh paths, so old entries simply expire. No reset API exposed.
|
|
62
|
+
void m
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
vi.restoreAllMocks()
|
|
68
|
+
vi.unstubAllEnvs()
|
|
69
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = undefined
|
|
70
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const SAMPLE_COMMIT = {
|
|
74
|
+
sha: 'a'.repeat(40),
|
|
75
|
+
commit: {
|
|
76
|
+
author: { name: 'Maria', email: 'maria@example.com', date: '2026-05-01T12:00:00Z' },
|
|
77
|
+
message: `Update hero\n\nCo-authored-by: Editor <editor@example.com>`,
|
|
78
|
+
},
|
|
79
|
+
author: { avatar_url: 'https://avatars.example.com/maria.png' },
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('GET /api/setzkasten/history', () => {
|
|
83
|
+
it('returns 401 without a session', async () => {
|
|
84
|
+
const { GET } = await import('../history')
|
|
85
|
+
const res = await (GET as (ctx: unknown) => Promise<Response>)(
|
|
86
|
+
makeCtx({ path: 'content/sections/hero.json' }, null),
|
|
87
|
+
)
|
|
88
|
+
expect(res.status).toBe(401)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('returns 403 for editor (admin-only)', async () => {
|
|
92
|
+
const { GET } = await import('../history')
|
|
93
|
+
const res = await (GET as (ctx: unknown) => Promise<Response>)(
|
|
94
|
+
makeCtx({ path: 'content/sections/hero.json' }, EDITOR_SESSION),
|
|
95
|
+
)
|
|
96
|
+
expect(res.status).toBe(403)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns 400 when path is missing', async () => {
|
|
100
|
+
const { GET } = await import('../history')
|
|
101
|
+
const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx({}))
|
|
102
|
+
expect(res.status).toBe(400)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns parsed commits with co-authors and short sha', async () => {
|
|
106
|
+
const fetchMock = vi.fn(async (url: string | URL) => {
|
|
107
|
+
const u = url instanceof URL ? url : new URL(url)
|
|
108
|
+
if (u.pathname.endsWith('/access_tokens')) {
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
json: async () => ({
|
|
112
|
+
token: 'gh_mock',
|
|
113
|
+
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
114
|
+
}),
|
|
115
|
+
} as Response
|
|
116
|
+
}
|
|
117
|
+
if (u.pathname.endsWith('/commits')) {
|
|
118
|
+
return { ok: true, status: 200, json: async () => [SAMPLE_COMMIT] } as Response
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`unexpected URL: ${u.toString()}`)
|
|
121
|
+
})
|
|
122
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
123
|
+
|
|
124
|
+
const { GET } = await import('../history')
|
|
125
|
+
// Use a fresh path each time to avoid cache hits from earlier tests
|
|
126
|
+
const res = await (GET as (ctx: unknown) => Promise<Response>)(
|
|
127
|
+
makeCtx({ path: 'content/sections/hero-' + Date.now() + '.json' }),
|
|
128
|
+
)
|
|
129
|
+
expect(res.status).toBe(200)
|
|
130
|
+
const body = await res.json()
|
|
131
|
+
expect(body.commits).toHaveLength(1)
|
|
132
|
+
expect(body.commits[0]).toMatchObject({
|
|
133
|
+
sha: 'a'.repeat(40),
|
|
134
|
+
shortSha: 'aaaaaaa',
|
|
135
|
+
authorName: 'Maria',
|
|
136
|
+
authorEmail: 'maria@example.com',
|
|
137
|
+
message: 'Update hero',
|
|
138
|
+
})
|
|
139
|
+
expect(body.commits[0].coAuthors).toEqual([
|
|
140
|
+
{ name: 'Editor', email: 'editor@example.com' },
|
|
141
|
+
])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('returns empty list when GitHub returns 404 for the path', async () => {
|
|
145
|
+
const fetchMock = vi.fn(async (url: string | URL) => {
|
|
146
|
+
const u = url instanceof URL ? url : new URL(url)
|
|
147
|
+
if (u.pathname.endsWith('/access_tokens')) {
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
json: async () => ({
|
|
151
|
+
token: 'gh_mock',
|
|
152
|
+
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
153
|
+
}),
|
|
154
|
+
} as Response
|
|
155
|
+
}
|
|
156
|
+
return { ok: false, status: 404, json: async () => ({}) } as Response
|
|
157
|
+
})
|
|
158
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
159
|
+
|
|
160
|
+
const { GET } = await import('../history')
|
|
161
|
+
const res = await (GET as (ctx: unknown) => Promise<Response>)(
|
|
162
|
+
makeCtx({ path: 'content/sections/missing-' + Date.now() + '.json' }),
|
|
163
|
+
)
|
|
164
|
+
expect(res.status).toBe(200)
|
|
165
|
+
const body = await res.json()
|
|
166
|
+
expect(body.commits).toEqual([])
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -67,6 +67,13 @@ describe('setup-github-app-callback', () => {
|
|
|
67
67
|
expect(cookieValue.appId).toBe('123456')
|
|
68
68
|
expect(cookieValue.slug).toBe('meine-cms-app')
|
|
69
69
|
expect(cookieValue.privateKey).toContain('RSA PRIVATE KEY')
|
|
70
|
+
// v1.2: GitHub-App also serves as the OAuth provider for admin
|
|
71
|
+
// login. The Manifest exchange returns client_id + client_secret
|
|
72
|
+
// alongside the App credentials; we capture both so the wizard
|
|
73
|
+
// can surface them as env vars and the CLI doesn't need to ask
|
|
74
|
+
// for OAuth credentials separately.
|
|
75
|
+
expect(cookieValue.clientId).toBe('Iv1.abc')
|
|
76
|
+
expect(cookieValue.clientSecret).toBe('secret')
|
|
70
77
|
})
|
|
71
78
|
|
|
72
79
|
it('Response ist nicht immutable — Cookie-Header kann gesetzt werden', async () => {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest'
|
|
6
|
+
import { signPayload } from '../_webhook-signing'
|
|
7
|
+
|
|
8
|
+
describe('signPayload', () => {
|
|
9
|
+
it('produces deterministic output for same input', () => {
|
|
10
|
+
const a = signPayload('{"hello":"world"}', 'secret')
|
|
11
|
+
const b = signPayload('{"hello":"world"}', 'secret')
|
|
12
|
+
expect(a).toBe(b)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('produces different output for different bodies', () => {
|
|
16
|
+
const a = signPayload('{"hello":"world"}', 'secret')
|
|
17
|
+
const b = signPayload('{"hello":"there"}', 'secret')
|
|
18
|
+
expect(a).not.toBe(b)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('produces different output for different secrets', () => {
|
|
22
|
+
const a = signPayload('{"hello":"world"}', 'secret-a')
|
|
23
|
+
const b = signPayload('{"hello":"world"}', 'secret-b')
|
|
24
|
+
expect(a).not.toBe(b)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('matches a known HMAC-SHA256 reference value', () => {
|
|
28
|
+
// Reference: `echo -n "test" | openssl dgst -sha256 -hmac "key"`
|
|
29
|
+
// → 02afb56304902c656fcb737cdd03de6205bb6d401da2812efd9b2d36a08af159
|
|
30
|
+
expect(signPayload('test', 'key')).toBe(
|
|
31
|
+
'02afb56304902c656fcb737cdd03de6205bb6d401da2812efd9b2d36a08af159',
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('returns a 64-char hex string', () => {
|
|
36
|
+
const sig = signPayload('any body', 'any secret')
|
|
37
|
+
expect(sig).toMatch(/^[0-9a-f]{64}$/)
|
|
38
|
+
})
|
|
39
|
+
})
|