@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.
Files changed (98) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -0
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -0
  3. package/assets/dashboard/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/assets/dashboard/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/assets/dashboard/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/assets/dashboard/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/assets/dashboard/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/assets/dashboard/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/assets/dashboard/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/assets/dashboard/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/assets/dashboard/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/assets/dashboard/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/assets/dashboard/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/assets/dashboard/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/assets/dashboard/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/assets/dashboard/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/assets/dashboard/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/assets/dashboard/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/assets/dashboard/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/assets/dashboard/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/assets/dashboard/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/assets/dashboard/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/assets/dashboard/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/assets/dashboard/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/assets/dashboard/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/assets/dashboard/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/assets/dashboard/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/assets/dashboard/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/assets/dashboard/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/assets/dashboard/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/assets/dashboard/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/assets/dashboard/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/assets/dashboard/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/assets/dashboard/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/assets/dashboard/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/assets/dashboard/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/assets/dashboard/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/assets/dashboard/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/assets/dashboard/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/assets/dashboard/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/assets/dashboard/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/assets/dashboard/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/assets/dashboard/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/assets/dashboard/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/assets/dashboard/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/assets/dashboard/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/assets/dashboard/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/assets/dashboard/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/assets/dashboard/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/assets/dashboard/assets/index-9Ch2xWJr.js +554 -0
  63. package/assets/dashboard/assets/index-ye3325L9.css +1 -0
  64. package/assets/dashboard/beechLogoDark.svg +48 -0
  65. package/assets/dashboard/index.html +18 -0
  66. package/assets/dashboard/sol.svg +3 -0
  67. package/assets/dashboard/undraw_clouds_bmtk.svg +1 -0
  68. package/assets/dashboard/undraw_enter_nwx3.svg +36 -0
  69. package/assets/dashboard/vite.svg +1 -0
  70. package/package.json +4 -3
  71. package/src/factory.ts +290 -21
  72. package/src/features/content/constants.ts +10 -0
  73. package/src/features/content/handlers/create.ts +163 -0
  74. package/src/features/content/handlers/delete.ts +85 -0
  75. package/src/features/content/handlers/facets.ts +45 -0
  76. package/src/features/content/handlers/get.ts +116 -0
  77. package/src/features/content/handlers/list.ts +88 -0
  78. package/src/features/content/handlers/update.ts +216 -0
  79. package/src/features/content/index.ts +20 -0
  80. package/src/features/draft/draft.handler.ts +203 -129
  81. package/src/features/password-reset/request.ts +1 -1
  82. package/src/features/schema/schema.handler.ts +16 -0
  83. package/src/features/settings/settings.handler.ts +18 -0
  84. package/src/index.ts +17 -337
  85. package/src/middleware/repository.middleware.ts +18 -0
  86. package/src/public/public-add.ts +72 -89
  87. package/src/public/public-edit.ts +51 -76
  88. package/src/public/public-read.ts +113 -114
  89. package/src/public/query-builder.ts +47 -136
  90. package/src/search-utils.ts +5 -1
  91. package/src/shared/base.repository.d1.ts +28 -0
  92. package/src/shared/content.repository.d1.ts +382 -0
  93. package/src/shared/idempotency.repository.d1.ts +45 -0
  94. package/src/types.ts +7 -1
  95. package/src/upload.ts +3 -7
  96. package/src/content.ts +0 -502
  97. package/src/features/draft/draft.test.ts +0 -315
  98. 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
- })