Skip to content

Commit efce375

Browse files
committed
fix: remediate audited tech debt and close functional test gaps
Backend: - validate PUT /admin/publication with a Zod schema (was raw json -> 500) - wrap category delete and entry membership/label writes in db.batch (atomic) - add JWT crypto-path tests (sig/exp/nbf/alg/issuer/audience), image/move/ menu-delete/hours route tests, rate-limiter and demo-bypass tests - packages/schemas: tests per module (was zero) Web: - surface errors on MenusPage toggle/reorder/delete and TranslationTabs translate - route confirm()/alert() through ConfirmDeleteModal where it fits - dedupe aiVoice<->aiChat coupling in SettingsPage - add api mutation, imageUpload, sanitizeI18nData, restaurantStore schedule, TranslationTabs, MenusPage, CategoriesPage tests Chat worker: - sanitize user content before WorkersAI prompt flattening (injection) - consume daily quota only after successful menu load; document KV race ceiling - unify token caps via shared constant; log silent JSON parse failures - align debug.ts with the real provider path - add allergen-exclusion, Anthropic/WorkersAI provider, and handler tests npm run health passes end-to-end.
1 parent 9db3058 commit efce375

54 files changed

Lines changed: 2181 additions & 85 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.pii-allowlist

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,9 @@ registry[.]npmjs[.]org
166166
(?i)^orari di apertura$
167167
(?i)^ravioli de$
168168
(?i)^hausgemacht$
169+
170+
# ── Test fixtures (not PII) ──
171+
# Fake URL used as the request target in chat worker handler tests.
172+
^https?://x/chat$
173+
# Fixed system-clock dates used in vitest rate-limit/daily-cap window tests.
174+
^2026-02-01T
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { describe, it, expect, beforeAll } from 'vitest';
2+
import { testRequest } from './helpers';
3+
import { createTestDb, makeDbEnv, seedSettings, seedMenu, seedCategory, seedEntry, signTestJwt, installJwksMock } from './helpers/db';
4+
5+
beforeAll(() => installJwksMock());
6+
7+
const ADMIN_UID = 'admin-1';
8+
const R2_BASE = 'https://cdn.test.local';
9+
10+
// Minimal in-memory R2 stub recording put/delete calls.
11+
function makeR2() {
12+
const objects = new Map<string, unknown>();
13+
const calls = { put: [] as string[], delete: [] as string[] };
14+
const bucket = {
15+
async put(key: string, value: unknown) {
16+
calls.put.push(key);
17+
objects.set(key, value);
18+
return { key } as R2Object;
19+
},
20+
async delete(key: string) {
21+
calls.delete.push(key);
22+
objects.delete(key);
23+
},
24+
async get() { return null; },
25+
async head() { return null; },
26+
async list() { return { objects: [], truncated: false } as unknown as R2Objects; },
27+
} as unknown as R2Bucket;
28+
return { bucket, objects, calls };
29+
}
30+
31+
function jpegBody(): ArrayBuffer {
32+
const buf = new Uint8Array(64);
33+
buf[0] = 0xff; buf[1] = 0xd8; buf[2] = 0xff;
34+
return buf.buffer;
35+
}
36+
37+
async function adminEnv(r2?: ReturnType<typeof makeR2>, db = createTestDb()) {
38+
seedSettings(db);
39+
const overrides: Record<string, unknown> = { ADMIN_EMAILS: ADMIN_UID };
40+
if (r2) {
41+
overrides.PUBLIC_MENU_BUCKET = r2.bucket;
42+
overrides.R2_PUBLIC_URL = R2_BASE;
43+
}
44+
const env = makeDbEnv(db, overrides);
45+
const token = await signTestJwt(ADMIN_UID);
46+
return { db, env, headers: { 'Cf-Access-Jwt-Assertion': token } };
47+
}
48+
49+
describe('POST /admin/entries/:id/image', () => {
50+
it('puts to R2 and stores image_url on the entry', async () => {
51+
const r2 = makeR2();
52+
const { db, env, headers } = await adminEnv(r2);
53+
seedCategory(db, 'cat-1');
54+
seedEntry(db, 'entry-1', 'cat-1');
55+
56+
const res = await testRequest('/admin/entries/entry-1/image', {
57+
method: 'POST', headers, env, body: jpegBody(),
58+
});
59+
expect(res.status).toBe(200);
60+
const body = await res.json() as { imageUrl: string };
61+
expect(r2.calls.put.some(k => k.startsWith('images/entries/entry-1-'))).toBe(true);
62+
expect(body.imageUrl.startsWith(`${R2_BASE}/images/entries/entry-1-`)).toBe(true);
63+
64+
const row = db.raw.prepare('SELECT image_url FROM menu_entries WHERE id = ?').get('entry-1') as { image_url: string };
65+
expect(row.image_url).toBe(body.imageUrl);
66+
});
67+
68+
it('returns 404 for a missing entry', async () => {
69+
const r2 = makeR2();
70+
const { env, headers } = await adminEnv(r2);
71+
const res = await testRequest('/admin/entries/nope/image', {
72+
method: 'POST', headers, env, body: jpegBody(),
73+
});
74+
expect(res.status).toBe(404);
75+
});
76+
});
77+
78+
describe('DELETE /admin/entries/:id/image', () => {
79+
it('deletes the old R2 object and nulls image_url', async () => {
80+
const r2 = makeR2();
81+
const { db, env, headers } = await adminEnv(r2);
82+
seedCategory(db, 'cat-1');
83+
seedEntry(db, 'entry-1', 'cat-1');
84+
const key = 'images/entries/entry-1-123.jpg';
85+
db.raw.prepare('UPDATE menu_entries SET image_url = ? WHERE id = ?').run(`${R2_BASE}/${key}`, 'entry-1');
86+
87+
const res = await testRequest('/admin/entries/entry-1/image', { method: 'DELETE', headers, env });
88+
expect(res.status).toBe(200);
89+
expect(r2.calls.delete).toContain(key);
90+
const row = db.raw.prepare('SELECT image_url FROM menu_entries WHERE id = ?').get('entry-1') as { image_url: string | null };
91+
expect(row.image_url).toBeNull();
92+
});
93+
});
94+
95+
describe('POST /admin/header-image', () => {
96+
it('uploads and stores headerImage on settings.info', async () => {
97+
const r2 = makeR2();
98+
const { db, env, headers } = await adminEnv(r2);
99+
100+
const res = await testRequest('/admin/header-image', {
101+
method: 'POST', headers, env, body: jpegBody(),
102+
});
103+
expect(res.status).toBe(200);
104+
const body = await res.json() as { imageUrl: string };
105+
expect(r2.calls.put.some(k => k.startsWith('images/settings/header-'))).toBe(true);
106+
107+
const row = db.raw.prepare('SELECT info FROM settings WHERE id = 1').get() as { info: string };
108+
expect(JSON.parse(row.info).headerImage).toBe(body.imageUrl);
109+
});
110+
});
111+
112+
describe('POST /admin/promotion-image', () => {
113+
it('uploads and stores url on settings.promotion_alert', async () => {
114+
const r2 = makeR2();
115+
const { db, env, headers } = await adminEnv(r2);
116+
117+
const res = await testRequest('/admin/promotion-image', {
118+
method: 'POST', headers, env, body: jpegBody(),
119+
});
120+
expect(res.status).toBe(200);
121+
const body = await res.json() as { imageUrl: string };
122+
123+
const row = db.raw.prepare('SELECT promotion_alert FROM settings WHERE id = 1').get() as { promotion_alert: string };
124+
expect(JSON.parse(row.promotion_alert).url).toBe(body.imageUrl);
125+
});
126+
});
127+
128+
describe('DELETE /admin/entries/:id removes its R2 image', () => {
129+
it('deletes the entry image object on entry delete', async () => {
130+
const r2 = makeR2();
131+
const { db, env, headers } = await adminEnv(r2);
132+
seedCategory(db, 'cat-1');
133+
seedEntry(db, 'entry-1', 'cat-1');
134+
const key = 'images/entries/entry-1-9.jpg';
135+
db.raw.prepare('UPDATE menu_entries SET image_url = ? WHERE id = ?').run(`${R2_BASE}/${key}`, 'entry-1');
136+
137+
const res = await testRequest('/admin/entries/entry-1', { method: 'DELETE', headers, env });
138+
expect(res.status).toBe(200);
139+
expect(r2.calls.delete).toContain(key);
140+
});
141+
});
142+
143+
describe('DELETE /admin/menus/:menuId FK cascade', () => {
144+
it('removes the menu and its memberships, keeps the entry', async () => {
145+
const { db, env, headers } = await adminEnv();
146+
seedMenu(db, 'm-1', 'food');
147+
seedCategory(db, 'cat-1');
148+
seedEntry(db, 'entry-1', 'cat-1');
149+
db.raw.prepare('INSERT INTO menu_entry_memberships (menu_id, entry_id) VALUES (?, ?)').run('m-1', 'entry-1');
150+
151+
const res = await testRequest('/admin/menus/m-1', { method: 'DELETE', headers, env });
152+
expect(res.status).toBe(200);
153+
expect(db.raw.prepare('SELECT COUNT(*) AS n FROM menus').get()).toEqual({ n: 0 });
154+
expect(db.raw.prepare('SELECT COUNT(*) AS n FROM menu_entry_memberships').get()).toEqual({ n: 0 });
155+
expect(db.raw.prepare('SELECT COUNT(*) AS n FROM menu_entries').get()).toEqual({ n: 1 });
156+
});
157+
});
158+
159+
describe('PUT /admin/hours', () => {
160+
it('persists a valid opening schedule', async () => {
161+
const { db, env, headers } = await adminEnv();
162+
const openingSchedule = {
163+
open: true,
164+
minWaitSlot: 0,
165+
slotDuration: 30,
166+
maxDaysLookAhead: 7,
167+
schedule: [[{ start: '09:00', end: '12:00' }], [], [], [], [], [], []],
168+
};
169+
const res = await testRequest('/admin/hours', {
170+
method: 'PUT', headers, env, body: { openingSchedule },
171+
});
172+
expect(res.status).toBe(200);
173+
const row = db.raw.prepare('SELECT opening_schedule FROM settings WHERE id = 1').get() as { opening_schedule: string };
174+
expect(JSON.parse(row.opening_schedule)).toEqual(openingSchedule);
175+
});
176+
177+
it('rejects a malformed schedule with 400', async () => {
178+
const { env, headers } = await adminEnv();
179+
const res = await testRequest('/admin/hours', {
180+
method: 'PUT', headers, env, body: { openingSchedule: { open: true } },
181+
});
182+
expect(res.status).toBe(400);
183+
});
184+
});
185+
186+
describe('POST /admin/entries/:id/move (404 path)', () => {
187+
it('returns 404 when target category is missing', async () => {
188+
const { db, env, headers } = await adminEnv();
189+
seedCategory(db, 'cat-1');
190+
seedEntry(db, 'entry-1', 'cat-1');
191+
const res = await testRequest('/admin/entries/entry-1/move', {
192+
method: 'POST', headers, env, body: { targetCategoryId: 'ghost' },
193+
});
194+
expect(res.status).toBe(404);
195+
});
196+
});
197+
198+
describe('PUT /admin/publication validation', () => {
199+
it('rejects a malformed body with 400 instead of 500', async () => {
200+
const { env, headers } = await adminEnv();
201+
const res = await testRequest('/admin/publication', {
202+
method: 'PUT', headers, env, body: { published: 'yes' },
203+
});
204+
expect(res.status).toBe(400);
205+
});
206+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, it, expect, beforeAll } from 'vitest';
2+
import { testRequest } from './helpers';
3+
import {
4+
createTestDb,
5+
makeDbEnv,
6+
signTestJwt,
7+
signTestJwtRaw,
8+
installJwksMock,
9+
TEST_JWT_ISSUER,
10+
TEST_JWT_AUDIENCE,
11+
TEST_JWT_KID,
12+
} from './helpers/db';
13+
14+
beforeAll(() => installJwksMock());
15+
16+
/**
17+
* Exercises the real verifyJwt crypto path through requireAuth on the
18+
* auth-only /admin/me route. The JWKS fetch is intercepted (installJwksMock)
19+
* so the actual signature verification, claim checks, and time-window checks
20+
* all run unmocked.
21+
*/
22+
const NOW = () => Math.floor(Date.now() / 1000);
23+
24+
async function meWith(token: string): Promise<Response> {
25+
const db = createTestDb();
26+
return testRequest('/admin/me', {
27+
headers: { 'Cf-Access-Jwt-Assertion': token },
28+
env: makeDbEnv(db),
29+
});
30+
}
31+
32+
describe('verifyJwt / requireAuth crypto path', () => {
33+
it('accepts a token with a valid signature and claims', async () => {
34+
const res = await meWith(await signTestJwt('user@test.com'));
35+
expect(res.status).toBe(200);
36+
});
37+
38+
it('rejects a token whose signature was tampered with', async () => {
39+
const token = await signTestJwt('user@test.com');
40+
const [h, p] = token.split('.');
41+
// Re-sign body with a different payload tail but keep the original signature:
42+
// mutate one char of the payload so the signature no longer matches.
43+
const tampered = `${h}.${p.slice(0, -2)}AA.${token.split('.')[2]}`;
44+
const res = await meWith(tampered);
45+
expect(res.status).toBe(401);
46+
});
47+
48+
it('rejects an expired token (exp in the past)', async () => {
49+
const res = await meWith(await signTestJwt('user@test.com', { exp: NOW() - 60 }));
50+
expect(res.status).toBe(401);
51+
});
52+
53+
it('rejects a not-yet-valid token (nbf in the future)', async () => {
54+
const res = await meWith(await signTestJwt('user@test.com', { nbf: NOW() + 3600 }));
55+
expect(res.status).toBe(401);
56+
});
57+
58+
it('rejects a non-RS256 algorithm', async () => {
59+
const token = await signTestJwtRaw(
60+
{ alg: 'HS256', kid: TEST_JWT_KID, typ: 'JWT' },
61+
{ email: 'user@test.com', iss: TEST_JWT_ISSUER, aud: TEST_JWT_AUDIENCE, exp: NOW() + 3600 },
62+
);
63+
const res = await meWith(token);
64+
expect(res.status).toBe(401);
65+
});
66+
67+
it('rejects a wrong issuer', async () => {
68+
const res = await meWith(await signTestJwt('user@test.com', { iss: 'https://evil.example.com' }));
69+
expect(res.status).toBe(401);
70+
});
71+
72+
it('rejects a wrong audience', async () => {
73+
const res = await meWith(await signTestJwt('user@test.com', { aud: 'some-other-aud' }));
74+
expect(res.status).toBe(401);
75+
});
76+
77+
it('rejects a token signed with an unknown kid', async () => {
78+
const token = await signTestJwtRaw(
79+
{ alg: 'RS256', kid: 'unknown-kid', typ: 'JWT' },
80+
{ email: 'user@test.com', iss: TEST_JWT_ISSUER, aud: TEST_JWT_AUDIENCE, exp: NOW() + 3600 },
81+
);
82+
const res = await meWith(token);
83+
expect(res.status).toBe(401);
84+
});
85+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, it, expect, beforeAll } from 'vitest';
2+
import { testRequest } from './helpers';
3+
import { createTestDb, makeDbEnv, seedSettings, installJwksMock } from './helpers/db';
4+
import { isDemoMode } from '../lib/demo';
5+
6+
beforeAll(() => installJwksMock());
7+
8+
/**
9+
* Pins the by-design demo-mode behavior: when DEMO_MODE === 'true' both
10+
* requireAuth and requireAdmin are bypassed. This is intentional (demo
11+
* deployments have no Cloudflare Access in front). These tests exist to make
12+
* any accidental change to that contract loud.
13+
*
14+
* Production safety: DEMO_MODE only activates on the exact string 'true'
15+
* (see isDemoMode). wrangler.toml ships DEMO_MODE = "false", and Env has no
16+
* default, so an unset/empty/any-other value leaves demo mode OFF.
17+
*/
18+
describe('demo mode auth bypass (by design)', () => {
19+
it('isDemoMode only treats the literal string "true" as enabled', () => {
20+
expect(isDemoMode({} as never)).toBe(false);
21+
expect(isDemoMode({ DEMO_MODE: 'false' } as never)).toBe(false);
22+
expect(isDemoMode({ DEMO_MODE: 'TRUE' } as never)).toBe(false);
23+
expect(isDemoMode({ DEMO_MODE: '1' } as never)).toBe(false);
24+
expect(isDemoMode({ DEMO_MODE: 'true' } as never)).toBe(true);
25+
});
26+
27+
it('reaches admin routes without any Access JWT when demo mode is on', async () => {
28+
const db = createTestDb();
29+
seedSettings(db);
30+
const res = await testRequest('/admin/settings', {
31+
env: makeDbEnv(db, { DEMO_MODE: 'true' }),
32+
});
33+
expect(res.status).toBe(200);
34+
});
35+
36+
it('still requires auth (401) when demo mode is off', async () => {
37+
const db = createTestDb();
38+
seedSettings(db);
39+
const res = await testRequest('/admin/settings', {
40+
env: makeDbEnv(db, { DEMO_MODE: 'false' }),
41+
});
42+
expect(res.status).toBe(401);
43+
});
44+
});

backend/src/__tests__/helpers.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,12 @@ export async function testRequest(path: string, options: TestRequestOptions = {}
3333

3434
const init: RequestInit = { method, headers };
3535
if (body !== undefined) {
36-
init.body = typeof body === 'string' ? body : JSON.stringify(body);
37-
init.headers = { 'Content-Type': 'application/json', ...headers };
36+
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
37+
init.body = body as BodyInit;
38+
} else {
39+
init.body = typeof body === 'string' ? body : JSON.stringify(body);
40+
init.headers = { 'Content-Type': 'application/json', ...headers };
41+
}
3842
}
3943

4044
const app = createApp();

backend/src/__tests__/helpers/db.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,31 @@ export async function signTestJwt(email: string, extra: Record<string, unknown>
288288
return `${signingInput}.${base64url(signature)}`;
289289
}
290290

291+
/**
292+
* Sign an arbitrary header + payload with the test RSA key. Used to exercise
293+
* the verifyJwt failure paths (wrong alg, missing kid, etc.) that signTestJwt
294+
* cannot produce because it always emits a well-formed RS256 header.
295+
*/
296+
export async function signTestJwtRaw(
297+
header: Record<string, unknown>,
298+
payload: Record<string, unknown>,
299+
): Promise<string> {
300+
await ensureKeyPair();
301+
const headerB64 = strToB64url(JSON.stringify(header));
302+
const payloadB64 = strToB64url(JSON.stringify(payload));
303+
const signingInput = `${headerB64}.${payloadB64}`;
304+
const signature = await crypto.subtle.sign(
305+
'RSASSA-PKCS1-v1_5',
306+
_keyPair!.privateKey,
307+
new TextEncoder().encode(signingInput),
308+
);
309+
return `${signingInput}.${base64url(signature)}`;
310+
}
311+
312+
export const TEST_JWT_ISSUER = TEST_ISSUER;
313+
export const TEST_JWT_AUDIENCE = TEST_AUDIENCE;
314+
export const TEST_JWT_KID = TEST_KID;
315+
291316
// ── Env factory ───────────────────────────────────────────────────────
292317

293318
/**

0 commit comments

Comments
 (0)