@beechcms/api 0.4.0-preview.1 → 0.4.0-preview.11
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/assets/dashboard/BeechLogo.svg +18 -0
- package/assets/dashboard/BeechLogoLIght.svg +48 -0
- package/assets/dashboard/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/assets/dashboard/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/assets/dashboard/assets/index-9Ch2xWJr.js +554 -0
- package/assets/dashboard/assets/index-ye3325L9.css +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -0
- package/assets/dashboard/index.html +18 -0
- package/assets/dashboard/sol.svg +3 -0
- package/assets/dashboard/undraw_clouds_bmtk.svg +1 -0
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -0
- package/assets/dashboard/vite.svg +1 -0
- package/package.json +4 -3
- package/src/factory.ts +290 -21
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +163 -0
- package/src/features/content/handlers/delete.ts +85 -0
- package/src/features/content/handlers/facets.ts +45 -0
- package/src/features/content/handlers/get.ts +116 -0
- package/src/features/content/handlers/list.ts +88 -0
- package/src/features/content/handlers/update.ts +216 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +203 -129
- package/src/features/password-reset/request.ts +1 -1
- package/src/features/schema/schema.handler.ts +16 -0
- package/src/features/settings/settings.handler.ts +18 -0
- package/src/index.ts +17 -337
- package/src/middleware/repository.middleware.ts +18 -0
- package/src/public/public-add.ts +72 -89
- package/src/public/public-edit.ts +51 -76
- package/src/public/public-read.ts +113 -114
- package/src/public/query-builder.ts +47 -136
- package/src/search-utils.ts +5 -1
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/idempotency.repository.d1.ts +45 -0
- package/src/types.ts +7 -1
- package/src/upload.ts +3 -7
- package/src/content.ts +0 -502
- package/src/features/draft/draft.test.ts +0 -315
- package/src/features/rotate-field/rotate-field.test.ts +0 -297
|
@@ -1,315 +0,0 @@
|
|
|
1
|
-
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
3
|
-
import type { Seed } from '@beechcms/core'
|
|
4
|
-
|
|
5
|
-
// --- Mock jose (auth bypass) ---
|
|
6
|
-
const mockJwtVerify = vi.hoisted(() => vi.fn())
|
|
7
|
-
vi.mock('jose', async (importOriginal) => {
|
|
8
|
-
const actual = await importOriginal<typeof import('jose')>()
|
|
9
|
-
return { ...actual, jwtVerify: mockJwtVerify }
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
// --- Seed di test (hoisted so they're available inside vi.mock factory) ---
|
|
13
|
-
const { DRAFT_SEED, NO_DRAFT_SEED } = vi.hoisted(() => {
|
|
14
|
-
const DRAFT_SEED = {
|
|
15
|
-
slug: 'test-articoli',
|
|
16
|
-
label: 'Articolo',
|
|
17
|
-
displayNameAlias: 'title',
|
|
18
|
-
allowDrafts: true,
|
|
19
|
-
branches: [
|
|
20
|
-
{ id: 'br_01', alias: 'title', label: 'Titolo', type: 'text', requiredOnCreate: true },
|
|
21
|
-
{ id: 'br_02', alias: 'body', label: 'Corpo', type: 'text' },
|
|
22
|
-
],
|
|
23
|
-
}
|
|
24
|
-
const NO_DRAFT_SEED = {
|
|
25
|
-
slug: 'test-messaggi',
|
|
26
|
-
label: 'Messaggio',
|
|
27
|
-
displayNameAlias: 'name',
|
|
28
|
-
branches: [
|
|
29
|
-
{ id: 'br_01', alias: 'name', label: 'Nome', type: 'text' },
|
|
30
|
-
],
|
|
31
|
-
}
|
|
32
|
-
return { DRAFT_SEED, NO_DRAFT_SEED }
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
vi.mock('@beechcms/core', async (importOriginal) => {
|
|
36
|
-
const actual = await importOriginal<typeof import('@beechcms/core')>()
|
|
37
|
-
return {
|
|
38
|
-
...actual,
|
|
39
|
-
SEED_REGISTRY: {
|
|
40
|
-
...actual.SEED_REGISTRY,
|
|
41
|
-
'test-articoli': DRAFT_SEED,
|
|
42
|
-
'test-messaggi': NO_DRAFT_SEED,
|
|
43
|
-
},
|
|
44
|
-
getSeed: (slug: string) => {
|
|
45
|
-
if (slug === 'test-articoli') return DRAFT_SEED
|
|
46
|
-
if (slug === 'test-messaggi') return NO_DRAFT_SEED
|
|
47
|
-
return actual.getSeed(slug)
|
|
48
|
-
},
|
|
49
|
-
}
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
import app from '../../index'
|
|
53
|
-
|
|
54
|
-
// --- Helpers ---
|
|
55
|
-
|
|
56
|
-
const ENTRY_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
57
|
-
|
|
58
|
-
function makeAuthHeader() {
|
|
59
|
-
return { Authorization: 'Bearer test-token' }
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* v0.4.0 Helper: returns a row as it would appear in content_{slug}
|
|
64
|
-
*/
|
|
65
|
-
function makeLiveRow(overrides: Record<string, any> = {}) {
|
|
66
|
-
return {
|
|
67
|
-
id: ENTRY_ID,
|
|
68
|
-
slug: 'test-article',
|
|
69
|
-
status: 'published',
|
|
70
|
-
title: 'Titolo live',
|
|
71
|
-
body: 'Corpo live',
|
|
72
|
-
created_at: 1_000_000,
|
|
73
|
-
updated_at: 1_000_000,
|
|
74
|
-
...overrides
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* v0.4.0 Helper: returns a row as it would appear in content_{slug}_drafts
|
|
80
|
-
*/
|
|
81
|
-
function makeDraftRow(overrides: Record<string, any> = {}) {
|
|
82
|
-
return {
|
|
83
|
-
entry_id: ENTRY_ID,
|
|
84
|
-
title: 'Titolo in bozza',
|
|
85
|
-
body: 'Corpo in bozza',
|
|
86
|
-
updated_at: 1_000_001,
|
|
87
|
-
...overrides
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function makeMockDB(options: {
|
|
92
|
-
liveRow?: object | null,
|
|
93
|
-
draftRow?: object | null,
|
|
94
|
-
bindCalls?: Array<{ sql: string; args: unknown[] }>
|
|
95
|
-
} = {}) {
|
|
96
|
-
const { liveRow = null, draftRow = null, bindCalls = [] } = options
|
|
97
|
-
|
|
98
|
-
const prepare = vi.fn((sql: string) => ({
|
|
99
|
-
bind: vi.fn((...args: unknown[]) => {
|
|
100
|
-
bindCalls.push({ sql, args })
|
|
101
|
-
return {
|
|
102
|
-
first: vi.fn(async () => {
|
|
103
|
-
if (sql.includes('_drafts')) return draftRow
|
|
104
|
-
return liveRow
|
|
105
|
-
}),
|
|
106
|
-
run: vi.fn(async () => ({ success: true, meta: { changes: 1 } })),
|
|
107
|
-
all: vi.fn(async () => ({ results: [] })),
|
|
108
|
-
}
|
|
109
|
-
}),
|
|
110
|
-
first: vi.fn(async () => {
|
|
111
|
-
if (sql.includes('_drafts')) return draftRow
|
|
112
|
-
return liveRow
|
|
113
|
-
}),
|
|
114
|
-
run: vi.fn(async () => ({ success: true, meta: { changes: 1 } })),
|
|
115
|
-
}))
|
|
116
|
-
|
|
117
|
-
return {
|
|
118
|
-
prepare,
|
|
119
|
-
batch: vi.fn(async (stmts: any[]) => {
|
|
120
|
-
// In tests, we just assume they run fine
|
|
121
|
-
return stmts.map(() => ({ success: true }))
|
|
122
|
-
})
|
|
123
|
-
} as unknown as D1Database
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// --- Suite ---
|
|
127
|
-
|
|
128
|
-
describe('Draft feature — PUT /:slug/:id/draft', () => {
|
|
129
|
-
beforeEach(() => {
|
|
130
|
-
mockJwtVerify.mockReset()
|
|
131
|
-
mockJwtVerify.mockResolvedValue({
|
|
132
|
-
payload: { sub: 'user-1', email: 'admin@beech.local' },
|
|
133
|
-
protectedHeader: { alg: 'HS256', typ: 'JWT' },
|
|
134
|
-
})
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
it('returns 200 when draft is saved', async () => {
|
|
138
|
-
const res = await app.request(
|
|
139
|
-
`/api/content/test-articoli/${ENTRY_ID}/draft`,
|
|
140
|
-
{
|
|
141
|
-
method: 'PUT',
|
|
142
|
-
headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
|
|
143
|
-
body: JSON.stringify({ title: 'Bozza titolo' }),
|
|
144
|
-
},
|
|
145
|
-
{ JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow() }) }
|
|
146
|
-
)
|
|
147
|
-
expect(res.status).toBe(200)
|
|
148
|
-
const json = await res.json<{ success: boolean }>()
|
|
149
|
-
expect(json.success).toBe(true)
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it('stores aliases as column names in content_{slug}_drafts', async () => {
|
|
153
|
-
const bindCalls: Array<{ sql: string; args: unknown[] }> = []
|
|
154
|
-
const mockDB = makeMockDB({ liveRow: makeLiveRow(), bindCalls })
|
|
155
|
-
|
|
156
|
-
await app.request(
|
|
157
|
-
`/api/content/test-articoli/${ENTRY_ID}/draft`,
|
|
158
|
-
{
|
|
159
|
-
method: 'PUT',
|
|
160
|
-
headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
|
|
161
|
-
body: JSON.stringify({ title: 'Titolo in bozza' }),
|
|
162
|
-
},
|
|
163
|
-
{ JWT_SECRET: 'test-secret', DB: mockDB }
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
const upsertCall = bindCalls.find((c) => c.sql.includes('INSERT INTO content_test-articoli_drafts'))
|
|
167
|
-
expect(upsertCall).toBeDefined()
|
|
168
|
-
// Column 'title' should be present in the SQL
|
|
169
|
-
expect(upsertCall!.sql).toContain('title')
|
|
170
|
-
// The value should be the second bind (first is ID)
|
|
171
|
-
expect(upsertCall!.args[1]).toBe('Titolo in bozza')
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
it('returns 405 when seed does not allow drafts', async () => {
|
|
175
|
-
const res = await app.request(
|
|
176
|
-
`/api/content/test-messaggi/${ENTRY_ID}/draft`,
|
|
177
|
-
{
|
|
178
|
-
method: 'PUT',
|
|
179
|
-
headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
|
|
180
|
-
body: JSON.stringify({ name: 'test' }),
|
|
181
|
-
},
|
|
182
|
-
{ JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow() }) }
|
|
183
|
-
)
|
|
184
|
-
expect(res.status).toBe(405)
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
it('returns 404 when entry does not exist', async () => {
|
|
188
|
-
const res = await app.request(
|
|
189
|
-
`/api/content/test-articoli/${ENTRY_ID}/draft`,
|
|
190
|
-
{
|
|
191
|
-
method: 'PUT',
|
|
192
|
-
headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
|
|
193
|
-
body: JSON.stringify({ title: 'Test' }),
|
|
194
|
-
},
|
|
195
|
-
{ JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: null }) }
|
|
196
|
-
)
|
|
197
|
-
expect(res.status).toBe(404)
|
|
198
|
-
})
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
describe('Draft feature — GET /:slug/:id/draft', () => {
|
|
202
|
-
beforeEach(() => {
|
|
203
|
-
mockJwtVerify.mockReset()
|
|
204
|
-
mockJwtVerify.mockResolvedValue({
|
|
205
|
-
payload: { sub: 'user-1', email: 'admin@beech.local' },
|
|
206
|
-
protectedHeader: { alg: 'HS256', typ: 'JWT' },
|
|
207
|
-
})
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
it('returns draft data with aliases', async () => {
|
|
211
|
-
const res = await app.request(
|
|
212
|
-
`/api/content/test-articoli/${ENTRY_ID}/draft`,
|
|
213
|
-
{ headers: makeAuthHeader() },
|
|
214
|
-
{ JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow(), draftRow: makeDraftRow() }) }
|
|
215
|
-
)
|
|
216
|
-
expect(res.status).toBe(200)
|
|
217
|
-
const json = await res.json<{ data: Record<string, unknown> }>()
|
|
218
|
-
expect(json.data.title).toBe('Titolo in bozza')
|
|
219
|
-
expect(json.data.body).toBe('Corpo in bozza')
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
it('returns 404 when no draft exists', async () => {
|
|
223
|
-
const res = await app.request(
|
|
224
|
-
`/api/content/test-articoli/${ENTRY_ID}/draft`,
|
|
225
|
-
{ headers: makeAuthHeader() },
|
|
226
|
-
{ JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow(), draftRow: null }) }
|
|
227
|
-
)
|
|
228
|
-
expect(res.status).toBe(404)
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
it('returns 404 when entry does not exist', async () => {
|
|
232
|
-
const res = await app.request(
|
|
233
|
-
`/api/content/test-articoli/${ENTRY_ID}/draft`,
|
|
234
|
-
{ headers: makeAuthHeader() },
|
|
235
|
-
{ JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: null }) }
|
|
236
|
-
)
|
|
237
|
-
expect(res.status).toBe(404)
|
|
238
|
-
})
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
describe('Draft feature — POST /:slug/:id/draft/publish', () => {
|
|
242
|
-
beforeEach(() => {
|
|
243
|
-
mockJwtVerify.mockReset()
|
|
244
|
-
mockJwtVerify.mockResolvedValue({
|
|
245
|
-
payload: { sub: 'user-1', email: 'admin@beech.local' },
|
|
246
|
-
protectedHeader: { alg: 'HS256', typ: 'JWT' },
|
|
247
|
-
})
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
it('returns 200 and promotes draft to live', async () => {
|
|
251
|
-
const res = await app.request(
|
|
252
|
-
`/api/content/test-articoli/${ENTRY_ID}/draft/publish`,
|
|
253
|
-
{ method: 'POST', headers: makeAuthHeader() },
|
|
254
|
-
{ JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow(), draftRow: makeDraftRow() }) }
|
|
255
|
-
)
|
|
256
|
-
expect(res.status).toBe(200)
|
|
257
|
-
const json = await res.json<{ success: boolean }>()
|
|
258
|
-
expect(json.success).toBe(true)
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
it('uses DB.batch to update live table and delete draft', async () => {
|
|
262
|
-
const mockDB = makeMockDB({ liveRow: makeLiveRow(), draftRow: makeDraftRow() })
|
|
263
|
-
const res = await app.request(
|
|
264
|
-
`/api/content/test-articoli/${ENTRY_ID}/draft/publish`,
|
|
265
|
-
{ method: 'POST', headers: makeAuthHeader() },
|
|
266
|
-
{ JWT_SECRET: 'test-secret', DB: mockDB }
|
|
267
|
-
)
|
|
268
|
-
expect(res.status).toBe(200)
|
|
269
|
-
expect(mockDB.batch).toHaveBeenCalled()
|
|
270
|
-
})
|
|
271
|
-
|
|
272
|
-
it('returns 404 when no pending draft exists', async () => {
|
|
273
|
-
const res = await app.request(
|
|
274
|
-
`/api/content/test-articoli/${ENTRY_ID}/draft/publish`,
|
|
275
|
-
{ method: 'POST', headers: makeAuthHeader() },
|
|
276
|
-
{ JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow(), draftRow: null }) }
|
|
277
|
-
)
|
|
278
|
-
expect(res.status).toBe(404)
|
|
279
|
-
})
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
describe('Draft feature — DELETE /:slug/:id/draft', () => {
|
|
283
|
-
beforeEach(() => {
|
|
284
|
-
mockJwtVerify.mockReset()
|
|
285
|
-
mockJwtVerify.mockResolvedValue({
|
|
286
|
-
payload: { sub: 'user-1', email: 'admin@beech.local' },
|
|
287
|
-
protectedHeader: { alg: 'HS256', typ: 'JWT' },
|
|
288
|
-
})
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
it('returns 200 when draft is discarded', async () => {
|
|
292
|
-
const res = await app.request(
|
|
293
|
-
`/api/content/test-articoli/${ENTRY_ID}/draft`,
|
|
294
|
-
{ method: 'DELETE', headers: makeAuthHeader() },
|
|
295
|
-
{ JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow(), draftRow: makeDraftRow() }) }
|
|
296
|
-
)
|
|
297
|
-
expect(res.status).toBe(200)
|
|
298
|
-
const json = await res.json<{ success: boolean }>()
|
|
299
|
-
expect(json.success).toBe(true)
|
|
300
|
-
})
|
|
301
|
-
|
|
302
|
-
it('issues DELETE FROM content_{slug}_drafts', async () => {
|
|
303
|
-
const bindCalls: Array<{ sql: string; args: unknown[] }> = []
|
|
304
|
-
const mockDB = makeMockDB({ liveRow: makeLiveRow(), bindCalls })
|
|
305
|
-
|
|
306
|
-
await app.request(
|
|
307
|
-
`/api/content/test-articoli/${ENTRY_ID}/draft`,
|
|
308
|
-
{ method: 'DELETE', headers: makeAuthHeader() },
|
|
309
|
-
{ JWT_SECRET: 'test-secret', DB: mockDB }
|
|
310
|
-
)
|
|
311
|
-
|
|
312
|
-
const deleteCall = bindCalls.find((c) => c.sql.includes('DELETE FROM content_test-articoli_drafts'))
|
|
313
|
-
expect(deleteCall).toBeDefined()
|
|
314
|
-
})
|
|
315
|
-
})
|
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
3
|
-
import type { Seed } from '@beechcms/core'
|
|
4
|
-
|
|
5
|
-
// --- Mock jose (auth bypass) ---
|
|
6
|
-
const mockJwtVerify = vi.hoisted(() => vi.fn())
|
|
7
|
-
vi.mock('jose', async (importOriginal) => {
|
|
8
|
-
const actual = await importOriginal<typeof import('jose')>()
|
|
9
|
-
return { ...actual, jwtVerify: mockJwtVerify }
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
// --- Test seed with a privacy:hash field (hoisted so it's available inside vi.mock factory) ---
|
|
13
|
-
const { HASH_SEED } = vi.hoisted(() => {
|
|
14
|
-
const HASH_SEED = {
|
|
15
|
-
slug: 'test-members',
|
|
16
|
-
label: 'Member',
|
|
17
|
-
displayNameAlias: 'name',
|
|
18
|
-
branches: [
|
|
19
|
-
{ id: 'br_01', alias: 'name', label: 'Name', type: 'text', requiredOnCreate: true },
|
|
20
|
-
{
|
|
21
|
-
id: 'br_02',
|
|
22
|
-
alias: 'password',
|
|
23
|
-
label: 'Password',
|
|
24
|
-
type: 'text',
|
|
25
|
-
policies: { privacy: 'hash' },
|
|
26
|
-
},
|
|
27
|
-
],
|
|
28
|
-
}
|
|
29
|
-
return { HASH_SEED }
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
// Override getSeed and SEED_REGISTRY for the test slug only
|
|
33
|
-
vi.mock('@beechcms/core', async (importOriginal) => {
|
|
34
|
-
const actual = await importOriginal<typeof import('@beechcms/core')>()
|
|
35
|
-
return {
|
|
36
|
-
...actual,
|
|
37
|
-
SEED_REGISTRY: {
|
|
38
|
-
...actual.SEED_REGISTRY,
|
|
39
|
-
'test-members': HASH_SEED,
|
|
40
|
-
},
|
|
41
|
-
getSeed: (slug: string) => (slug === 'test-members' ? HASH_SEED : actual.getSeed(slug)),
|
|
42
|
-
}
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
import app from '../../index'
|
|
46
|
-
|
|
47
|
-
// --- Helpers ---
|
|
48
|
-
|
|
49
|
-
async function sha256hex(value: string): Promise<string> {
|
|
50
|
-
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
|
|
51
|
-
return Array.from(new Uint8Array(buf))
|
|
52
|
-
.map((b) => b.toString(16).padStart(2, '0'))
|
|
53
|
-
.join('')
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function makeAuthHeader() {
|
|
57
|
-
return { Authorization: 'Bearer test-token' }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// v0.4.0: entry has real columns (no JSON blob)
|
|
61
|
-
function makeEntry(id: string, dbData: Record<string, unknown>) {
|
|
62
|
-
return {
|
|
63
|
-
id,
|
|
64
|
-
slug: 'member-1',
|
|
65
|
-
status: 'published',
|
|
66
|
-
name: dbData.name ?? null,
|
|
67
|
-
password: dbData.password ?? dbData.br_02 ?? null, // accept both alias and old id for compat
|
|
68
|
-
created_at: 1000000,
|
|
69
|
-
updated_at: 1000000,
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function makeMockDB(row: object | null, updateCapture?: { called: boolean }) {
|
|
74
|
-
return {
|
|
75
|
-
prepare: vi.fn((sql: string) => ({
|
|
76
|
-
bind: vi.fn(() => ({
|
|
77
|
-
first: vi.fn(() => Promise.resolve(row)),
|
|
78
|
-
run: vi.fn(() => {
|
|
79
|
-
if (updateCapture && sql.includes('UPDATE')) updateCapture.called = true
|
|
80
|
-
return Promise.resolve({ success: true, meta: { changes: 1 } })
|
|
81
|
-
}),
|
|
82
|
-
all: vi.fn(() => Promise.resolve({ results: row ? [row] : [] })),
|
|
83
|
-
})),
|
|
84
|
-
first: vi.fn(() => Promise.resolve(row)),
|
|
85
|
-
run: vi.fn(() => Promise.resolve({ success: true, meta: { changes: 1 } })),
|
|
86
|
-
})),
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const ENTRY_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
91
|
-
const CURRENT_PLAINTEXT = 'old-secret'
|
|
92
|
-
const NEXT_PLAINTEXT = 'new-secret'
|
|
93
|
-
|
|
94
|
-
// --- Tests ---
|
|
95
|
-
|
|
96
|
-
describe('POST /:slug/:id/rotate-field', () => {
|
|
97
|
-
beforeEach(() => {
|
|
98
|
-
mockJwtVerify.mockReset()
|
|
99
|
-
mockJwtVerify.mockResolvedValue({
|
|
100
|
-
payload: { sub: 'user-1', email: 'admin@beech.local' },
|
|
101
|
-
protectedHeader: { alg: 'HS256', typ: 'JWT' },
|
|
102
|
-
})
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
it('returns 200 and updates the field when current matches', async () => {
|
|
106
|
-
const storedHash = await sha256hex(CURRENT_PLAINTEXT)
|
|
107
|
-
const updateCapture = { called: false }
|
|
108
|
-
const mockDB = makeMockDB(makeEntry(ENTRY_ID, { br_02: storedHash }), updateCapture)
|
|
109
|
-
|
|
110
|
-
const res = await app.request(
|
|
111
|
-
`/api/content/test-members/${ENTRY_ID}/rotate-field`,
|
|
112
|
-
{
|
|
113
|
-
method: 'POST',
|
|
114
|
-
headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
|
|
115
|
-
body: JSON.stringify({ field: 'password', current: CURRENT_PLAINTEXT, next: NEXT_PLAINTEXT }),
|
|
116
|
-
},
|
|
117
|
-
{ JWT_SECRET: 'test-secret', DB: mockDB }
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
expect(res.status).toBe(200)
|
|
121
|
-
const json = await res.json<{ success: boolean }>()
|
|
122
|
-
expect(json.success).toBe(true)
|
|
123
|
-
expect(updateCapture.called).toBe(true)
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
it('returns 403 when current does not match stored hash', async () => {
|
|
127
|
-
const storedHash = await sha256hex(CURRENT_PLAINTEXT)
|
|
128
|
-
const mockDB = makeMockDB(makeEntry(ENTRY_ID, { br_02: storedHash }))
|
|
129
|
-
|
|
130
|
-
const res = await app.request(
|
|
131
|
-
`/api/content/test-members/${ENTRY_ID}/rotate-field`,
|
|
132
|
-
{
|
|
133
|
-
method: 'POST',
|
|
134
|
-
headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
|
|
135
|
-
body: JSON.stringify({ field: 'password', current: 'wrong-value', next: NEXT_PLAINTEXT }),
|
|
136
|
-
},
|
|
137
|
-
{ JWT_SECRET: 'test-secret', DB: mockDB }
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
expect(res.status).toBe(403)
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
it('returns 404 when entry does not exist', async () => {
|
|
144
|
-
const mockDB = makeMockDB(null)
|
|
145
|
-
|
|
146
|
-
const res = await app.request(
|
|
147
|
-
`/api/content/test-members/${ENTRY_ID}/rotate-field`,
|
|
148
|
-
{
|
|
149
|
-
method: 'POST',
|
|
150
|
-
headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
|
|
151
|
-
body: JSON.stringify({ field: 'password', current: CURRENT_PLAINTEXT, next: NEXT_PLAINTEXT }),
|
|
152
|
-
},
|
|
153
|
-
{ JWT_SECRET: 'test-secret', DB: mockDB }
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
expect(res.status).toBe(404)
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
it('returns 404 when seed does not exist', async () => {
|
|
160
|
-
const mockDB = makeMockDB(null)
|
|
161
|
-
|
|
162
|
-
const res = await app.request(
|
|
163
|
-
`/api/content/unknown-seed/${ENTRY_ID}/rotate-field`,
|
|
164
|
-
{
|
|
165
|
-
method: 'POST',
|
|
166
|
-
headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
|
|
167
|
-
body: JSON.stringify({ field: 'password', current: CURRENT_PLAINTEXT, next: NEXT_PLAINTEXT }),
|
|
168
|
-
},
|
|
169
|
-
{ JWT_SECRET: 'test-secret', DB: mockDB }
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
expect(res.status).toBe(404)
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
it('returns 400 when field does not exist in the seed', async () => {
|
|
176
|
-
const storedHash = await sha256hex(CURRENT_PLAINTEXT)
|
|
177
|
-
const mockDB = makeMockDB(makeEntry(ENTRY_ID, { br_02: storedHash }))
|
|
178
|
-
|
|
179
|
-
const res = await app.request(
|
|
180
|
-
`/api/content/test-members/${ENTRY_ID}/rotate-field`,
|
|
181
|
-
{
|
|
182
|
-
method: 'POST',
|
|
183
|
-
headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
|
|
184
|
-
body: JSON.stringify({ field: 'nonexistent', current: CURRENT_PLAINTEXT, next: NEXT_PLAINTEXT }),
|
|
185
|
-
},
|
|
186
|
-
{ JWT_SECRET: 'test-secret', DB: mockDB }
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
expect(res.status).toBe(400)
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
it('returns 422 when field does not use hash privacy', async () => {
|
|
193
|
-
const mockDB = makeMockDB(makeEntry(ENTRY_ID, { br_01: 'Alice' }))
|
|
194
|
-
|
|
195
|
-
const res = await app.request(
|
|
196
|
-
`/api/content/test-members/${ENTRY_ID}/rotate-field`,
|
|
197
|
-
{
|
|
198
|
-
method: 'POST',
|
|
199
|
-
headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
|
|
200
|
-
body: JSON.stringify({ field: 'name', current: CURRENT_PLAINTEXT, next: NEXT_PLAINTEXT }),
|
|
201
|
-
},
|
|
202
|
-
{ JWT_SECRET: 'test-secret', DB: mockDB }
|
|
203
|
-
)
|
|
204
|
-
|
|
205
|
-
expect(res.status).toBe(422)
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
it('returns 422 when the field has no stored value', async () => {
|
|
209
|
-
// Entry exists but br_02 is not in data
|
|
210
|
-
const mockDB = makeMockDB(makeEntry(ENTRY_ID, { br_01: 'Alice' }))
|
|
211
|
-
|
|
212
|
-
const res = await app.request(
|
|
213
|
-
`/api/content/test-members/${ENTRY_ID}/rotate-field`,
|
|
214
|
-
{
|
|
215
|
-
method: 'POST',
|
|
216
|
-
headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
|
|
217
|
-
body: JSON.stringify({ field: 'password', current: CURRENT_PLAINTEXT, next: NEXT_PLAINTEXT }),
|
|
218
|
-
},
|
|
219
|
-
{ JWT_SECRET: 'test-secret', DB: mockDB }
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
expect(res.status).toBe(422)
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
it('returns 400 when body is missing required fields', async () => {
|
|
226
|
-
const mockDB = makeMockDB(null)
|
|
227
|
-
|
|
228
|
-
const res = await app.request(
|
|
229
|
-
`/api/content/test-members/${ENTRY_ID}/rotate-field`,
|
|
230
|
-
{
|
|
231
|
-
method: 'POST',
|
|
232
|
-
headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
|
|
233
|
-
body: JSON.stringify({ field: 'password' }), // missing current and next
|
|
234
|
-
},
|
|
235
|
-
{ JWT_SECRET: 'test-secret', DB: mockDB }
|
|
236
|
-
)
|
|
237
|
-
|
|
238
|
-
expect(res.status).toBe(400)
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
it('returns 401 when not authenticated', async () => {
|
|
242
|
-
mockJwtVerify.mockRejectedValueOnce(new Error('invalid token'))
|
|
243
|
-
const mockDB = makeMockDB(null)
|
|
244
|
-
|
|
245
|
-
const res = await app.request(
|
|
246
|
-
`/api/content/test-members/${ENTRY_ID}/rotate-field`,
|
|
247
|
-
{
|
|
248
|
-
method: 'POST',
|
|
249
|
-
headers: { 'Content-Type': 'application/json' },
|
|
250
|
-
body: JSON.stringify({ field: 'password', current: CURRENT_PLAINTEXT, next: NEXT_PLAINTEXT }),
|
|
251
|
-
},
|
|
252
|
-
{ JWT_SECRET: 'test-secret', DB: mockDB }
|
|
253
|
-
)
|
|
254
|
-
|
|
255
|
-
expect(res.status).toBe(401)
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
it('stores a new SHA-256 hash (not the plaintext) after rotation', async () => {
|
|
259
|
-
const storedHash = await sha256hex(CURRENT_PLAINTEXT)
|
|
260
|
-
const bindCalls: Array<{ sql: string; args: unknown[] }> = []
|
|
261
|
-
|
|
262
|
-
const mockDB = {
|
|
263
|
-
prepare: vi.fn((sql: string) => ({
|
|
264
|
-
bind: vi.fn((...args: unknown[]) => {
|
|
265
|
-
bindCalls.push({ sql, args })
|
|
266
|
-
return {
|
|
267
|
-
first: vi.fn(() => Promise.resolve(makeEntry(ENTRY_ID, { br_02: storedHash }))),
|
|
268
|
-
run: vi.fn(() => Promise.resolve({ success: true, meta: { changes: 1 } })),
|
|
269
|
-
all: vi.fn(() => Promise.resolve({ results: [] })),
|
|
270
|
-
}
|
|
271
|
-
}),
|
|
272
|
-
first: vi.fn(() => Promise.resolve(makeEntry(ENTRY_ID, { br_02: storedHash }))),
|
|
273
|
-
run: vi.fn(() => Promise.resolve({ success: true, meta: { changes: 1 } })),
|
|
274
|
-
})),
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const res = await app.request(
|
|
278
|
-
`/api/content/test-members/${ENTRY_ID}/rotate-field`,
|
|
279
|
-
{
|
|
280
|
-
method: 'POST',
|
|
281
|
-
headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
|
|
282
|
-
body: JSON.stringify({ field: 'password', current: CURRENT_PLAINTEXT, next: NEXT_PLAINTEXT }),
|
|
283
|
-
},
|
|
284
|
-
{ JWT_SECRET: 'test-secret', DB: mockDB }
|
|
285
|
-
)
|
|
286
|
-
|
|
287
|
-
expect(res.status).toBe(200)
|
|
288
|
-
// v0.4.0: UPDATE content_test-members SET password = ?, updated_at = ? WHERE id = ?
|
|
289
|
-
const updateCall = bindCalls.find((c) => c.sql.includes('UPDATE content_test-members'))
|
|
290
|
-
expect(updateCall).toBeDefined()
|
|
291
|
-
// args[0] = password hash value (real column, not JSON blob)
|
|
292
|
-
const savedHash = updateCall!.args[0] as string
|
|
293
|
-
expect(savedHash).not.toBe(NEXT_PLAINTEXT)
|
|
294
|
-
expect(savedHash).toMatch(/^[0-9a-f]{64}$/)
|
|
295
|
-
expect(savedHash).toBe(await sha256hex(NEXT_PLAINTEXT))
|
|
296
|
-
})
|
|
297
|
-
})
|