@beechcms/api 0.4.0-preview.1
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/README.md +21 -0
- package/migrations/0000_v040_base.sql +213 -0
- package/package.json +36 -0
- package/src/auth/constants.ts +10 -0
- package/src/auth/login.ts +91 -0
- package/src/auth/refresh.ts +127 -0
- package/src/content.ts +502 -0
- package/src/factory.ts +56 -0
- package/src/features/draft/draft.handler.ts +198 -0
- package/src/features/draft/draft.test.ts +315 -0
- package/src/features/draft/index.ts +1 -0
- package/src/features/email/email.provider.ts +38 -0
- package/src/features/email/email.service.ts +80 -0
- package/src/features/email/email.types.ts +98 -0
- package/src/features/email/index.ts +28 -0
- package/src/features/email/providers/resend.ts +63 -0
- package/src/features/email/templates/password-changed.ts +59 -0
- package/src/features/email/templates/password-reset.ts +64 -0
- package/src/features/email/templates/shell.ts +93 -0
- package/src/features/notifications/index.ts +1 -0
- package/src/features/notifications/notifications.handler.ts +88 -0
- package/src/features/password-reset/index.ts +15 -0
- package/src/features/password-reset/request.ts +88 -0
- package/src/features/password-reset/reset.ts +110 -0
- package/src/features/rotate-field/index.ts +1 -0
- package/src/features/rotate-field/rotate-field.handler.ts +82 -0
- package/src/features/rotate-field/rotate-field.schema.ts +9 -0
- package/src/features/rotate-field/rotate-field.test.ts +297 -0
- package/src/features/settings/settings.handler.ts +249 -0
- package/src/features/setup/index.ts +59 -0
- package/src/features/stats/index.ts +1 -0
- package/src/features/stats/stats.handler.ts +395 -0
- package/src/index.ts +344 -0
- package/src/media-utils.ts +78 -0
- package/src/middleware.ts +67 -0
- package/src/public/access-policy.ts +23 -0
- package/src/public/api-key-middleware.ts +53 -0
- package/src/public/index.ts +12 -0
- package/src/public/problem-details.ts +42 -0
- package/src/public/public-add.ts +183 -0
- package/src/public/public-edit.ts +183 -0
- package/src/public/public-errors.ts +15 -0
- package/src/public/public-read.ts +217 -0
- package/src/public/public-routes.ts +31 -0
- package/src/public/query-builder.ts +241 -0
- package/src/public/rate-limit-middleware.ts +42 -0
- package/src/public/response-builder.ts +26 -0
- package/src/public/sanitize.ts +65 -0
- package/src/public/slug-utils.ts +14 -0
- package/src/search-utils.ts +188 -0
- package/src/search.ts +72 -0
- package/src/shared/activity-logger.ts +79 -0
- package/src/shared/apply-policies.ts +63 -0
- package/src/shared/content-utils.ts +108 -0
- package/src/shared/fts-sync.ts +4 -0
- package/src/shared/notification-service.ts +56 -0
- package/src/shared/query-utils.ts +137 -0
- package/src/shared/storage-utils.ts +36 -0
- package/src/types.ts +35 -0
- package/src/upload.ts +335 -0
- package/src/widget.ts +349 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import type { Context } from 'hono'
|
|
3
|
+
import bcrypt from 'bcryptjs'
|
|
4
|
+
import type { Env, Variables } from '../../types'
|
|
5
|
+
import { sendPasswordChangedEmail, resolveEmailLocale } from '../email'
|
|
6
|
+
|
|
7
|
+
const MIN_PASSWORD_LENGTH = 8
|
|
8
|
+
const MAX_PASSWORD_LENGTH = 128
|
|
9
|
+
|
|
10
|
+
async function sha256hex(text: string): Promise<string> {
|
|
11
|
+
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
|
|
12
|
+
return Array.from(new Uint8Array(buf))
|
|
13
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
14
|
+
.join('')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function resetPassword(
|
|
18
|
+
c: Context<{ Bindings: Env; Variables: Variables }>
|
|
19
|
+
): Promise<Response> {
|
|
20
|
+
if (!c.env.RESEND_API_KEY) {
|
|
21
|
+
return c.json({ error: 'Not available' }, 503)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Rate limiting: 5 tentativi per IP per 60 secondi
|
|
25
|
+
if (c.env.RESET_PASSWORD_RATE_LIMITER) {
|
|
26
|
+
const ip = c.req.raw.headers.get('cf-connecting-ip') ?? 'unknown'
|
|
27
|
+
const { success } = await c.env.RESET_PASSWORD_RATE_LIMITER.limit({ key: ip })
|
|
28
|
+
if (!success) {
|
|
29
|
+
return c.json({ error: 'Too many requests' }, 429)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let body: Record<string, unknown>
|
|
34
|
+
try {
|
|
35
|
+
body = await c.req.json()
|
|
36
|
+
} catch {
|
|
37
|
+
return c.json({ error: 'Invalid request body' }, 400)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (typeof body.token !== 'string' || !body.token) {
|
|
41
|
+
return c.json({ error: 'Invalid or expired token' }, 400)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (
|
|
45
|
+
typeof body.password !== 'string' ||
|
|
46
|
+
body.password.length < MIN_PASSWORD_LENGTH ||
|
|
47
|
+
body.password.length > MAX_PASSWORD_LENGTH
|
|
48
|
+
) {
|
|
49
|
+
return c.json({
|
|
50
|
+
error: `Password must be between ${MIN_PASSWORD_LENGTH} and ${MAX_PASSWORD_LENGTH} characters`,
|
|
51
|
+
}, 400)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const locale = resolveEmailLocale(body.locale)
|
|
55
|
+
const tokenHash = await sha256hex(body.token)
|
|
56
|
+
const now = Math.floor(Date.now() / 1000)
|
|
57
|
+
|
|
58
|
+
// JOIN users per recuperare l'email in un'unica query — serve per la notifica
|
|
59
|
+
const record = await c.env.DB
|
|
60
|
+
.prepare(
|
|
61
|
+
`SELECT prt.id, prt.user_id, u.email
|
|
62
|
+
FROM password_reset_tokens prt
|
|
63
|
+
JOIN users u ON u.id = prt.user_id
|
|
64
|
+
WHERE prt.token_hash = ? AND prt.expires_at > ? AND prt.used_at IS NULL`,
|
|
65
|
+
)
|
|
66
|
+
.bind(tokenHash, now)
|
|
67
|
+
.first<{ id: string; user_id: string; email: string }>()
|
|
68
|
+
|
|
69
|
+
if (!record) {
|
|
70
|
+
return c.json({ error: 'Invalid or expired token' }, 400)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const newHash = await bcrypt.hash(body.password, 10)
|
|
74
|
+
|
|
75
|
+
// Segna token usato, aggiorna password, revoca tutte le sessioni — atomicamente
|
|
76
|
+
await c.env.DB.batch([
|
|
77
|
+
c.env.DB
|
|
78
|
+
.prepare('UPDATE password_reset_tokens SET used_at = unixepoch() WHERE id = ?')
|
|
79
|
+
.bind(record.id),
|
|
80
|
+
c.env.DB
|
|
81
|
+
.prepare('UPDATE users SET password_hash = ? WHERE id = ?')
|
|
82
|
+
.bind(newHash, record.user_id),
|
|
83
|
+
c.env.DB
|
|
84
|
+
.prepare('UPDATE refresh_tokens SET revoked_at = unixepoch() WHERE user_id = ? AND revoked_at IS NULL')
|
|
85
|
+
.bind(record.user_id),
|
|
86
|
+
])
|
|
87
|
+
|
|
88
|
+
// Notifica "password modificata" — fire-and-forget via waitUntil, non blocca il 200
|
|
89
|
+
const notify = () =>
|
|
90
|
+
sendPasswordChangedEmail({
|
|
91
|
+
to: record.email,
|
|
92
|
+
locale,
|
|
93
|
+
apiKey: c.env.RESEND_API_KEY!,
|
|
94
|
+
from: c.env.EMAIL_FROM,
|
|
95
|
+
isDev: c.env.ENV !== 'production',
|
|
96
|
+
}).catch((err) => {
|
|
97
|
+
if (c.env.ENV !== 'production') {
|
|
98
|
+
console.error('[password-reset] notifica email fallita:', err)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
c.executionCtx.waitUntil(notify())
|
|
104
|
+
} catch {
|
|
105
|
+
// executionCtx non disponibile in ambiente di test
|
|
106
|
+
void notify()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return c.json({ success: true })
|
|
110
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { rotateFieldApp } from './rotate-field.handler'
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
import { resolvePolicies, verifyHashField, sha256hex, validateAndSanitizeSeedPayload, serializeForDb } from '@beechcms/core'
|
|
4
|
+
import { publicProblem } from '../../public/problem-details'
|
|
5
|
+
import { rotateFieldBodySchema } from './rotate-field.schema'
|
|
6
|
+
import type { Env, Variables } from '../../types'
|
|
7
|
+
|
|
8
|
+
const rotateFieldApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
9
|
+
|
|
10
|
+
rotateFieldApp.post('/:slug/:id/rotate-field', async (c) => {
|
|
11
|
+
const slug = c.req.param('slug')
|
|
12
|
+
const id = c.req.param('id')
|
|
13
|
+
|
|
14
|
+
const seed = c.get('getSeed')(slug)
|
|
15
|
+
if (!seed) {
|
|
16
|
+
return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: `Seed '${slug}' not found` })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let rawBody: unknown
|
|
20
|
+
try {
|
|
21
|
+
rawBody = await c.req.json()
|
|
22
|
+
} catch {
|
|
23
|
+
return publicProblem(c, { type: 'rotate-field-invalid-json', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const parsed = rotateFieldBodySchema.safeParse(rawBody)
|
|
27
|
+
if (!parsed.success) {
|
|
28
|
+
return publicProblem(c, { type: 'rotate-field-invalid-body', title: 'Bad Request', status: 400, detail: parsed.error.issues[0]?.message ?? 'Invalid body' })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { field, current, next } = parsed.data
|
|
32
|
+
|
|
33
|
+
const branch = seed.branches.find((b) => b.alias === field)
|
|
34
|
+
if (!branch) {
|
|
35
|
+
return publicProblem(c, { type: 'rotate-field-unknown-field', title: 'Bad Request', status: 400, detail: `Field '${field}' does not exist in seed '${slug}'` })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { privacy } = resolvePolicies(branch)
|
|
39
|
+
if (privacy !== 'hash') {
|
|
40
|
+
return publicProblem(c, { type: 'rotate-field-not-hashable', title: 'Unprocessable Entity', status: 422, detail: `Field '${field}' does not use hash privacy and cannot be rotated with this endpoint` })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { DB } = c.env
|
|
44
|
+
// In v0.4.0 il valore hash è in una colonna reale: row[branch.alias]
|
|
45
|
+
const row = await DB.prepare(`SELECT ${branch.alias} FROM content_${slug} WHERE id = ? LIMIT 1`)
|
|
46
|
+
.bind(id)
|
|
47
|
+
.first<Record<string, unknown>>()
|
|
48
|
+
|
|
49
|
+
if (!row) {
|
|
50
|
+
return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found` })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const storedHash = row[branch.alias]
|
|
54
|
+
|
|
55
|
+
if (typeof storedHash !== 'string' || storedHash.length === 0) {
|
|
56
|
+
return publicProblem(c, { type: 'rotate-field-not-set', title: 'Unprocessable Entity', status: 422, detail: `Field '${field}' has no stored value to rotate` })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const matches = await verifyHashField(storedHash, current)
|
|
60
|
+
if (!matches) {
|
|
61
|
+
return publicProblem(c, { type: 'rotate-field-current-mismatch', title: 'Forbidden', status: 403, detail: 'Current value does not match stored value' })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const validation = validateAndSanitizeSeedPayload(
|
|
65
|
+
seed, { [field]: next },
|
|
66
|
+
{ operation: 'update', allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: false }
|
|
67
|
+
)
|
|
68
|
+
if (validation.details.length > 0) {
|
|
69
|
+
return publicProblem(c, { type: 'rotate-field-invalid-next', title: 'Bad Request', status: 400, detail: `Invalid value for field '${field}': ${validation.details[0]?.message ?? 'validation failed'}` })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const nextHash = await sha256hex(next)
|
|
73
|
+
const now = Math.floor(Date.now() / 1000)
|
|
74
|
+
|
|
75
|
+
await DB.prepare(`UPDATE content_${slug} SET ${branch.alias} = ?, updated_at = ? WHERE id = ?`)
|
|
76
|
+
.bind(serializeForDb(branch, nextHash), now, id)
|
|
77
|
+
.run()
|
|
78
|
+
|
|
79
|
+
return c.json({ success: true })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
export { rotateFieldApp }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const rotateFieldBodySchema = z.object({
|
|
4
|
+
field: z.string().min(1, "field is required"),
|
|
5
|
+
current: z.string().min(1, "current is required"),
|
|
6
|
+
next: z.string().min(1, "next is required"),
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
export type RotateFieldBody = z.infer<typeof rotateFieldBodySchema>
|
|
@@ -0,0 +1,297 @@
|
|
|
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
|
+
})
|