Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'test: forms builder + server validation tests (#412, #413)' (#250) from test/batch9-forms-server-validation into main

scott ecd4f117 5679f887

+967 -82
+9 -40
server/index.ts
··· 10 10 import compression from 'compression'; 11 11 import type { IncomingMessage } from 'http'; 12 12 import type { Duplex } from 'stream'; 13 + import { isValidDocId, RateLimiter, isValidDocType, isValidMimeType, filterMetadata, sanitizeAiRequest } from './validation.js'; 13 14 14 15 // --- Interfaces --- 15 16 ··· 326 327 deleteBlobsForDoc: db.prepare('DELETE FROM blobs WHERE document_id = ?'), 327 328 }; 328 329 329 - /** Validate document/blob ID: UUID format or reasonable alphanumeric */ 330 - function isValidDocId(id: string): boolean { 331 - return typeof id === 'string' && id.length > 0 && id.length <= 100 && /^[a-zA-Z0-9_-]+$/.test(id); 332 - } 333 - 334 - /** Simple in-memory rate limiter per key (user or IP) */ 335 - const rateLimitMap = new Map<string, { count: number; resetAt: number }>(); 336 - function rateLimit(key: string, maxPerWindow: number, windowMs: number): boolean { 337 - const now = Date.now(); 338 - const entry = rateLimitMap.get(key); 339 - if (!entry || now >= entry.resetAt) { 340 - rateLimitMap.set(key, { count: 1, resetAt: now + windowMs }); 341 - return true; 342 - } 343 - if (entry.count >= maxPerWindow) return false; 344 - entry.count++; 345 - return true; 346 - } 330 + const rateLimiter = new RateLimiter(); 347 331 // Periodically clean up expired entries (every 60s) 348 - setInterval(() => { 349 - const now = Date.now(); 350 - for (const [key, entry] of rateLimitMap) { 351 - if (now >= entry.resetAt) rateLimitMap.delete(key); 352 - } 353 - }, 60000).unref(); 332 + setInterval(() => rateLimiter.cleanup(), 60000).unref(); 354 333 355 334 // --- Express --- 356 335 const app = express(); ··· 441 420 app.post('/api/documents', (req: Request<Record<string, string>, unknown, CreateDocumentBody> & { tsUser?: TailscaleUser | null }, res: Response) => { 442 421 const id = randomUUID(); 443 422 const { type, name_encrypted } = req.body; 444 - if (!type || !['doc', 'sheet', 'form', 'slide', 'diagram'].includes(type)) { 423 + if (!isValidDocType(type)) { 445 424 res.status(400).json({ error: 'type must be doc, sheet, form, slide, or diagram' }); 446 425 return; 447 426 } ··· 533 512 // Note: :id is already validated by app.param('id') middleware 534 513 // Rate limit: 60 snapshot writes per minute per user 535 514 const rlKey = `snap:${req.tsUser?.login || 'anon'}`; 536 - if (!rateLimit(rlKey, 60, 60000)) { 515 + if (!rateLimiter.check(rlKey, 60, 60000)) { 537 516 res.status(429).json({ error: 'Too many snapshot writes, please slow down' }); 538 517 return; 539 518 } ··· 664 643 } 665 644 666 645 // Merge incoming body into existing metadata (whitelisted keys only) 667 - const allowedMetaKeys = ['label', 'description', 'starred', 'color', 'tags']; 668 646 let existing: Record<string, unknown> = {}; 669 647 if (version.metadata) { 670 648 try { 671 649 existing = JSON.parse(version.metadata) as Record<string, unknown>; 672 650 } catch { /* ignore parse errors */ } 673 651 } 674 - const incoming: Record<string, unknown> = {}; 675 - if (req.body && typeof req.body === 'object') { 676 - for (const key of allowedMetaKeys) { 677 - if (key in req.body) incoming[key] = req.body[key]; 678 - } 679 - } 652 + const incoming = filterMetadata(req.body); 680 653 const merged = { ...existing, ...incoming }; 681 654 682 655 db.prepare('UPDATE versions SET metadata = ? WHERE id = ? AND document_id = ?') ··· 692 665 const gatewayUrl = `${AI_GATEWAY_URL.replace(/\/$/, '')}/v1/chat/completions`; 693 666 try { 694 667 // Whitelist allowed fields to prevent arbitrary payload forwarding 695 - const allowedKeys = ['model', 'messages', 'temperature', 'max_tokens', 'stream', 'top_p', 'stop', 'presence_penalty', 'frequency_penalty']; 696 - const sanitized: Record<string, unknown> = {}; 697 - for (const key of allowedKeys) { 698 - if (req.body && key in req.body) sanitized[key] = req.body[key]; 699 - } 700 - if (!sanitized.messages || !Array.isArray(sanitized.messages)) { 668 + const sanitized = sanitizeAiRequest(req.body); 669 + if (!sanitized) { 701 670 res.status(400).json({ error: 'messages array is required' }); 702 671 return; 703 672 } ··· 793 762 const docId = req.headers['x-document-id'] as string; 794 763 const fileName = (req.headers['x-file-name'] as string || 'file').slice(0, 255); 795 764 const rawMime = (req.headers['x-mime-type'] as string || 'application/octet-stream').slice(0, 255); 796 - const mimeType = /^[\w.+-]+\/[\w.+-]+$/.test(rawMime) ? rawMime : 'application/octet-stream'; 765 + const mimeType = isValidMimeType(rawMime) ? rawMime : 'application/octet-stream'; 797 766 if (!docId) return res.status(400).json({ error: 'x-document-id header required' }); 798 767 const data = req.body; 799 768 if (!data || !data.length) return res.status(400).json({ error: 'No data' });
+89
server/validation.ts
··· 1 + /** 2 + * Server validation utilities — extracted for testability. 3 + * Used by server/index.ts for document ID validation, rate limiting, etc. 4 + */ 5 + 6 + /** Validate document/blob ID: UUID format or reasonable alphanumeric */ 7 + export function isValidDocId(id: string): boolean { 8 + return typeof id === 'string' && id.length > 0 && id.length <= 100 && /^[a-zA-Z0-9_-]+$/.test(id); 9 + } 10 + 11 + /** Simple in-memory rate limiter per key (user or IP) */ 12 + export class RateLimiter { 13 + private map = new Map<string, { count: number; resetAt: number }>(); 14 + 15 + /** 16 + * Check if a request is allowed under the rate limit. 17 + * Returns true if allowed, false if rate-limited. 18 + */ 19 + check(key: string, maxPerWindow: number, windowMs: number): boolean { 20 + const now = Date.now(); 21 + const entry = this.map.get(key); 22 + if (!entry || now >= entry.resetAt) { 23 + this.map.set(key, { count: 1, resetAt: now + windowMs }); 24 + return true; 25 + } 26 + if (entry.count >= maxPerWindow) return false; 27 + entry.count++; 28 + return true; 29 + } 30 + 31 + /** Remove expired entries */ 32 + cleanup(): void { 33 + const now = Date.now(); 34 + for (const [key, entry] of this.map) { 35 + if (now >= entry.resetAt) this.map.delete(key); 36 + } 37 + } 38 + 39 + /** Clear all entries (for testing) */ 40 + clear(): void { 41 + this.map.clear(); 42 + } 43 + } 44 + 45 + /** Validate that a share_mode value is valid */ 46 + export function isValidShareMode(mode: unknown): mode is 'edit' | 'view' { 47 + return mode === 'edit' || mode === 'view'; 48 + } 49 + 50 + /** Validate a document type */ 51 + export function isValidDocType(type: unknown): type is 'doc' | 'sheet' | 'form' | 'slide' | 'diagram' { 52 + return typeof type === 'string' && ['doc', 'sheet', 'form', 'slide', 'diagram'].includes(type); 53 + } 54 + 55 + /** Validate a MIME type string (basic format check) */ 56 + export function isValidMimeType(mime: string): boolean { 57 + return /^[\w.+-]+\/[\w.+-]+$/.test(mime); 58 + } 59 + 60 + /** Validate that a metadata key is in the allowed set */ 61 + const ALLOWED_META_KEYS = ['label', 'description', 'starred', 'color', 'tags'] as const; 62 + export type MetadataKey = typeof ALLOWED_META_KEYS[number]; 63 + 64 + export function filterMetadata(body: unknown): Record<string, unknown> { 65 + const result: Record<string, unknown> = {}; 66 + if (body && typeof body === 'object' && !Array.isArray(body)) { 67 + for (const key of ALLOWED_META_KEYS) { 68 + if (key in (body as Record<string, unknown>)) { 69 + result[key] = (body as Record<string, unknown>)[key]; 70 + } 71 + } 72 + } 73 + return result; 74 + } 75 + 76 + /** AI proxy request field whitelist */ 77 + const AI_ALLOWED_KEYS = ['model', 'messages', 'temperature', 'max_tokens', 'stream', 'top_p', 'stop', 'presence_penalty', 'frequency_penalty'] as const; 78 + 79 + export function sanitizeAiRequest(body: unknown): Record<string, unknown> | null { 80 + if (!body || typeof body !== 'object' || Array.isArray(body)) return null; 81 + const sanitized: Record<string, unknown> = {}; 82 + for (const key of AI_ALLOWED_KEYS) { 83 + if (key in (body as Record<string, unknown>)) { 84 + sanitized[key] = (body as Record<string, unknown>)[key]; 85 + } 86 + } 87 + if (!sanitized.messages || !Array.isArray(sanitized.messages)) return null; 88 + return sanitized; 89 + }
+518 -42
tests/form-builder.test.ts
··· 13 13 questionCount, 14 14 requiredCount, 15 15 duplicateForm, 16 + type QuestionType, 16 17 type Question, 18 + type FormSchema, 17 19 } from '../src/forms/form-builder.js'; 18 20 21 + // --- createForm --- 22 + 19 23 describe('createForm', () => { 20 - it('creates empty form', () => { 24 + it('creates empty form with defaults', () => { 21 25 const form = createForm('Survey'); 22 26 expect(form.title).toBe('Survey'); 27 + expect(form.description).toBe(''); 23 28 expect(form.questions).toEqual([]); 24 29 expect(form.targetSheetId).toBeNull(); 30 + expect(form.id).toMatch(/^form-/); 31 + expect(form.createdAt).toBeGreaterThan(0); 32 + expect(form.updatedAt).toBe(form.createdAt); 33 + }); 34 + 35 + it('creates form with custom description', () => { 36 + const form = createForm('Survey', 'Please answer honestly'); 37 + expect(form.description).toBe('Please answer honestly'); 25 38 }); 26 39 27 40 it('generates unique IDs', () => { ··· 31 44 }); 32 45 }); 33 46 47 + // --- addQuestion --- 48 + 34 49 describe('addQuestion', () => { 35 - it('adds question to form', () => { 50 + it('adds question to form with defaults', () => { 36 51 let form = createForm('Test'); 37 52 form = addQuestion(form, 'short_text', 'Name'); 38 53 expect(form.questions).toHaveLength(1); 39 54 expect(form.questions[0].label).toBe('Name'); 40 55 expect(form.questions[0].type).toBe('short_text'); 56 + expect(form.questions[0].description).toBe(''); 57 + expect(form.questions[0].required).toBe(false); 58 + expect(form.questions[0].options).toEqual([]); 59 + expect(form.questions[0].id).toMatch(/^q-/); 41 60 }); 42 61 43 - it('accepts options', () => { 62 + it('accepts required + description options', () => { 44 63 let form = createForm('Test'); 45 64 form = addQuestion(form, 'email', 'Email', { required: true, description: 'Your email' }); 46 65 expect(form.questions[0].required).toBe(true); 47 66 expect(form.questions[0].description).toBe('Your email'); 48 67 }); 68 + 69 + it('adds question with scale range', () => { 70 + let form = createForm('Test'); 71 + form = addQuestion(form, 'scale', 'Satisfaction', { scaleMin: 0, scaleMax: 10 }); 72 + expect(form.questions[0].scaleMin).toBe(0); 73 + expect(form.questions[0].scaleMax).toBe(10); 74 + }); 75 + 76 + it('adds question with validation pattern', () => { 77 + let form = createForm('Test'); 78 + form = addQuestion(form, 'short_text', 'Zip', { validationPattern: '^\\d{5}$' }); 79 + expect(form.questions[0].validationPattern).toBe('^\\d{5}$'); 80 + }); 81 + 82 + it('preserves existing questions when adding', () => { 83 + let form = createForm('Test'); 84 + form = addQuestion(form, 'short_text', 'Q1'); 85 + form = addQuestion(form, 'number', 'Q2'); 86 + form = addQuestion(form, 'email', 'Q3'); 87 + expect(form.questions).toHaveLength(3); 88 + expect(form.questions[0].label).toBe('Q1'); 89 + expect(form.questions[2].label).toBe('Q3'); 90 + }); 91 + 92 + it('updates updatedAt timestamp', () => { 93 + const form = createForm('Test'); 94 + const updated = addQuestion(form, 'short_text', 'Q1'); 95 + expect(updated.updatedAt).toBeGreaterThanOrEqual(form.updatedAt); 96 + }); 97 + 98 + it('does not mutate the original form', () => { 99 + const form = createForm('Test'); 100 + const updated = addQuestion(form, 'short_text', 'Q1'); 101 + expect(form.questions).toHaveLength(0); 102 + expect(updated.questions).toHaveLength(1); 103 + }); 104 + 105 + it('supports all question types', () => { 106 + const types: QuestionType[] = [ 107 + 'short_text', 'long_text', 'number', 'email', 'url', 108 + 'single_choice', 'multiple_choice', 'dropdown', 109 + 'date', 'rating', 'scale', 'file_upload', 110 + ]; 111 + let form = createForm('Test'); 112 + for (const type of types) { 113 + form = addQuestion(form, type, `${type} question`); 114 + } 115 + expect(form.questions).toHaveLength(types.length); 116 + for (let i = 0; i < types.length; i++) { 117 + expect(form.questions[i].type).toBe(types[i]); 118 + } 119 + }); 49 120 }); 121 + 122 + // --- removeQuestion --- 50 123 51 124 describe('removeQuestion', () => { 52 125 it('removes question by ID', () => { ··· 58 131 expect(form.questions).toHaveLength(1); 59 132 expect(form.questions[0].label).toBe('Q2'); 60 133 }); 134 + 135 + it('returns form with same questions for non-existent ID', () => { 136 + let form = createForm('Test'); 137 + form = addQuestion(form, 'short_text', 'Q1'); 138 + const updated = removeQuestion(form, 'nonexistent'); 139 + expect(updated.questions).toHaveLength(1); 140 + }); 141 + 142 + it('does not mutate the original form', () => { 143 + let form = createForm('Test'); 144 + form = addQuestion(form, 'short_text', 'Q1'); 145 + const id = form.questions[0].id; 146 + const updated = removeQuestion(form, id); 147 + expect(form.questions).toHaveLength(1); 148 + expect(updated.questions).toHaveLength(0); 149 + }); 150 + 151 + it('can remove all questions one by one', () => { 152 + let form = createForm('Test'); 153 + form = addQuestion(form, 'short_text', 'Q1'); 154 + form = addQuestion(form, 'short_text', 'Q2'); 155 + form = removeQuestion(form, form.questions[0].id); 156 + form = removeQuestion(form, form.questions[0].id); 157 + expect(form.questions).toHaveLength(0); 158 + }); 61 159 }); 62 160 161 + // --- updateQuestion --- 162 + 63 163 describe('updateQuestion', () => { 64 164 it('updates question properties', () => { 65 165 let form = createForm('Test'); ··· 68 168 form = updateQuestion(form, id, { label: 'Full Name', required: true }); 69 169 expect(form.questions[0].label).toBe('Full Name'); 70 170 expect(form.questions[0].required).toBe(true); 171 + }); 172 + 173 + it('preserves unspecified properties', () => { 174 + let form = createForm('Test'); 175 + form = addQuestion(form, 'short_text', 'Q1', { required: true, description: 'desc' }); 176 + const id = form.questions[0].id; 177 + form = updateQuestion(form, id, { label: 'Changed' }); 178 + expect(form.questions[0].required).toBe(true); 179 + expect(form.questions[0].description).toBe('desc'); 180 + expect(form.questions[0].type).toBe('short_text'); 181 + }); 182 + 183 + it('does not affect other questions', () => { 184 + let form = createForm('Test'); 185 + form = addQuestion(form, 'short_text', 'Q1'); 186 + form = addQuestion(form, 'number', 'Q2'); 187 + const id = form.questions[0].id; 188 + form = updateQuestion(form, id, { label: 'Changed' }); 189 + expect(form.questions[1].label).toBe('Q2'); 190 + }); 191 + 192 + it('leaves question unchanged for non-existent ID', () => { 193 + let form = createForm('Test'); 194 + form = addQuestion(form, 'short_text', 'Q1'); 195 + const updated = updateQuestion(form, 'nonexistent', { label: 'X' }); 196 + expect(updated.questions[0].label).toBe('Q1'); 197 + }); 198 + 199 + it('can update type', () => { 200 + let form = createForm('Test'); 201 + form = addQuestion(form, 'short_text', 'Q1'); 202 + const id = form.questions[0].id; 203 + form = updateQuestion(form, id, { type: 'long_text' }); 204 + expect(form.questions[0].type).toBe('long_text'); 71 205 }); 72 206 }); 73 207 208 + // --- moveQuestion --- 209 + 74 210 describe('moveQuestion', () => { 75 - it('moves question to new index', () => { 211 + function buildForm(): FormSchema { 76 212 let form = createForm('Test'); 77 213 form = addQuestion(form, 'short_text', 'A'); 78 - form = addQuestion(form, 'short_text', 'B'); 79 - form = addQuestion(form, 'short_text', 'C'); 214 + form = addQuestion(form, 'number', 'B'); 215 + form = addQuestion(form, 'email', 'C'); 216 + return form; 217 + } 218 + 219 + it('moves last to first', () => { 220 + const form = buildForm(); 80 221 const id = form.questions[2].id; 81 - form = moveQuestion(form, id, 0); 82 - expect(form.questions[0].label).toBe('C'); 83 - expect(form.questions[1].label).toBe('A'); 222 + const moved = moveQuestion(form, id, 0); 223 + expect(moved.questions[0].label).toBe('C'); 224 + expect(moved.questions[1].label).toBe('A'); 225 + expect(moved.questions[2].label).toBe('B'); 84 226 }); 85 227 86 - it('clamps to bounds', () => { 87 - let form = createForm('Test'); 88 - form = addQuestion(form, 'short_text', 'A'); 89 - form = addQuestion(form, 'short_text', 'B'); 90 - form = moveQuestion(form, form.questions[0].id, 100); 91 - expect(form.questions[1].label).toBe('A'); 228 + it('moves first to last', () => { 229 + const form = buildForm(); 230 + const id = form.questions[0].id; 231 + const moved = moveQuestion(form, id, 2); 232 + expect(moved.questions.map(q => q.label)).toEqual(['B', 'C', 'A']); 233 + }); 234 + 235 + it('moves to same position (no-op)', () => { 236 + const form = buildForm(); 237 + const id = form.questions[1].id; 238 + const moved = moveQuestion(form, id, 1); 239 + expect(moved.questions.map(q => q.label)).toEqual(['A', 'B', 'C']); 240 + }); 241 + 242 + it('clamps negative index to 0', () => { 243 + const form = buildForm(); 244 + const id = form.questions[2].id; 245 + const moved = moveQuestion(form, id, -5); 246 + expect(moved.questions[0].label).toBe('C'); 247 + }); 248 + 249 + it('clamps excessive index to end', () => { 250 + const form = buildForm(); 251 + form.questions[0].id; 252 + const moved = moveQuestion(form, form.questions[0].id, 100); 253 + expect(moved.questions[2].label).toBe('A'); 92 254 }); 93 255 94 256 it('returns same form for unknown ID', () => { 95 257 const form = createForm('Test'); 96 258 expect(moveQuestion(form, 'unknown', 0)).toBe(form); 97 259 }); 260 + 261 + it('does not mutate the original form', () => { 262 + const form = buildForm(); 263 + const id = form.questions[0].id; 264 + moveQuestion(form, id, 2); 265 + expect(form.questions.map(q => q.label)).toEqual(['A', 'B', 'C']); 266 + }); 98 267 }); 268 + 269 + // --- createOption / addOption --- 99 270 100 271 describe('createOption / addOption', () => { 101 272 it('creates option with unique ID', () => { ··· 103 274 const o2 = createOption('No'); 104 275 expect(o1.id).not.toBe(o2.id); 105 276 expect(o1.label).toBe('Yes'); 277 + expect(o1.id).toMatch(/^opt-/); 106 278 }); 107 279 108 280 it('adds option to question', () => { ··· 112 284 form = addOption(form, qId, 'Red'); 113 285 form = addOption(form, qId, 'Blue'); 114 286 expect(form.questions[0].options).toHaveLength(2); 287 + expect(form.questions[0].options[0].label).toBe('Red'); 288 + expect(form.questions[0].options[1].label).toBe('Blue'); 289 + }); 290 + 291 + it('does not affect other questions', () => { 292 + let form = createForm('Test'); 293 + form = addQuestion(form, 'single_choice', 'Q1'); 294 + form = addQuestion(form, 'short_text', 'Q2'); 295 + const qId = form.questions[0].id; 296 + form = addOption(form, qId, 'Opt'); 297 + expect(form.questions[1].options).toEqual([]); 115 298 }); 116 299 }); 300 + 301 + // --- setTargetSheet --- 117 302 118 303 describe('setTargetSheet', () => { 119 304 it('sets target sheet', () => { ··· 128 313 form = setTargetSheet(form, null); 129 314 expect(form.targetSheetId).toBeNull(); 130 315 }); 316 + 317 + it('updates updatedAt', () => { 318 + const form = createForm('Test'); 319 + const updated = setTargetSheet(form, 'sheet-1'); 320 + expect(updated.updatedAt).toBeGreaterThanOrEqual(form.updatedAt); 321 + }); 131 322 }); 132 323 324 + // --- validateAnswer --- 325 + 133 326 describe('validateAnswer', () => { 134 - const textQ: Question = { id: 'q1', type: 'short_text', label: 'Name', description: '', required: true, options: [] }; 135 - const emailQ: Question = { id: 'q2', type: 'email', label: 'Email', description: '', required: false, options: [] }; 136 - const numberQ: Question = { id: 'q3', type: 'number', label: 'Age', description: '', required: false, options: [] }; 137 - const choiceQ: Question = { 138 - id: 'q4', type: 'single_choice', label: 'Pick', description: '', required: false, 139 - options: [{ id: 'o1', label: 'A' }, { id: 'o2', label: 'B' }], 140 - }; 141 - const ratingQ: Question = { id: 'q5', type: 'rating', label: 'Rate', description: '', required: false, options: [], scaleMin: 1, scaleMax: 5 }; 327 + function makeQ(overrides: Partial<Question> & { type: QuestionType }): Question { 328 + return { 329 + id: 'test-q', 330 + type: overrides.type, 331 + label: 'Test', 332 + description: '', 333 + required: overrides.required ?? false, 334 + options: overrides.options ?? [], 335 + scaleMin: overrides.scaleMin, 336 + scaleMax: overrides.scaleMax, 337 + validationPattern: overrides.validationPattern, 338 + }; 339 + } 142 340 143 - it('validates required field', () => { 144 - expect(validateAnswer(textQ, '')).toBe('This field is required'); 145 - expect(validateAnswer(textQ, 'Alice')).toBeNull(); 341 + describe('required field', () => { 342 + it('rejects empty string', () => { 343 + const q = makeQ({ type: 'short_text', required: true }); 344 + expect(validateAnswer(q, '')).toBe('This field is required'); 345 + }); 346 + 347 + it('rejects null', () => { 348 + const q = makeQ({ type: 'short_text', required: true }); 349 + expect(validateAnswer(q, null)).toBe('This field is required'); 350 + }); 351 + 352 + it('rejects undefined', () => { 353 + const q = makeQ({ type: 'short_text', required: true }); 354 + expect(validateAnswer(q, undefined)).toBe('This field is required'); 355 + }); 356 + 357 + it('passes with value', () => { 358 + const q = makeQ({ type: 'short_text', required: true }); 359 + expect(validateAnswer(q, 'Alice')).toBeNull(); 360 + }); 361 + 362 + it('allows empty for non-required', () => { 363 + const q = makeQ({ type: 'short_text', required: false }); 364 + expect(validateAnswer(q, '')).toBeNull(); 365 + expect(validateAnswer(q, null)).toBeNull(); 366 + expect(validateAnswer(q, undefined)).toBeNull(); 367 + }); 146 368 }); 147 369 148 - it('allows empty for non-required', () => { 149 - expect(validateAnswer(emailQ, '')).toBeNull(); 370 + describe('email validation', () => { 371 + it('accepts valid email', () => { 372 + const q = makeQ({ type: 'email' }); 373 + expect(validateAnswer(q, 'user@example.com')).toBeNull(); 374 + }); 375 + 376 + it('rejects email without @', () => { 377 + const q = makeQ({ type: 'email' }); 378 + expect(validateAnswer(q, 'notanemail')).toBe('Invalid email address'); 379 + }); 380 + 381 + it('rejects email without domain', () => { 382 + const q = makeQ({ type: 'email' }); 383 + expect(validateAnswer(q, 'user@')).toBe('Invalid email address'); 384 + }); 385 + 386 + it('rejects email with spaces', () => { 387 + const q = makeQ({ type: 'email' }); 388 + expect(validateAnswer(q, 'user @example.com')).toBe('Invalid email address'); 389 + }); 150 390 }); 151 391 152 - it('validates email format', () => { 153 - expect(validateAnswer(emailQ, 'bad')).toBe('Invalid email address'); 154 - expect(validateAnswer(emailQ, 'a@b.c')).toBeNull(); 392 + describe('url validation', () => { 393 + it('accepts http URL', () => { 394 + const q = makeQ({ type: 'url' }); 395 + expect(validateAnswer(q, 'http://example.com')).toBeNull(); 396 + }); 397 + 398 + it('accepts https URL', () => { 399 + const q = makeQ({ type: 'url' }); 400 + expect(validateAnswer(q, 'https://example.com/path?q=1')).toBeNull(); 401 + }); 402 + 403 + it('rejects non-http protocol', () => { 404 + const q = makeQ({ type: 'url' }); 405 + expect(validateAnswer(q, 'ftp://example.com')).toBe('Invalid URL'); 406 + }); 407 + 408 + it('rejects plain text', () => { 409 + const q = makeQ({ type: 'url' }); 410 + expect(validateAnswer(q, 'not a url')).toBe('Invalid URL'); 411 + }); 155 412 }); 156 413 157 - it('validates number', () => { 158 - expect(validateAnswer(numberQ, 'abc')).toBe('Must be a number'); 159 - expect(validateAnswer(numberQ, '25')).toBeNull(); 414 + describe('number validation', () => { 415 + it('accepts integers', () => { 416 + const q = makeQ({ type: 'number' }); 417 + expect(validateAnswer(q, '42')).toBeNull(); 418 + }); 419 + 420 + it('accepts floats', () => { 421 + const q = makeQ({ type: 'number' }); 422 + expect(validateAnswer(q, '3.14')).toBeNull(); 423 + }); 424 + 425 + it('accepts negative numbers', () => { 426 + const q = makeQ({ type: 'number' }); 427 + expect(validateAnswer(q, '-7')).toBeNull(); 428 + }); 429 + 430 + it('rejects non-numeric text', () => { 431 + const q = makeQ({ type: 'number' }); 432 + expect(validateAnswer(q, 'abc')).toBe('Must be a number'); 433 + }); 434 + 435 + it('accepts zero', () => { 436 + const q = makeQ({ type: 'number' }); 437 + expect(validateAnswer(q, '0')).toBeNull(); 438 + }); 160 439 }); 161 440 162 - it('validates choice', () => { 163 - expect(validateAnswer(choiceQ, 'A')).toBeNull(); 164 - expect(validateAnswer(choiceQ, 'o1')).toBeNull(); 165 - expect(validateAnswer(choiceQ, 'C')).toBe('Invalid choice'); 441 + describe('date validation', () => { 442 + it('accepts valid ISO date', () => { 443 + const q = makeQ({ type: 'date' }); 444 + expect(validateAnswer(q, '2026-01-15')).toBeNull(); 445 + }); 446 + 447 + it('rejects invalid date', () => { 448 + const q = makeQ({ type: 'date' }); 449 + expect(validateAnswer(q, 'not-a-date')).toBe('Invalid date'); 450 + }); 166 451 }); 167 452 168 - it('validates rating range', () => { 169 - expect(validateAnswer(ratingQ, '3')).toBeNull(); 170 - expect(validateAnswer(ratingQ, '0')).toBe('Must be between 1 and 5'); 171 - expect(validateAnswer(ratingQ, '6')).toBe('Must be between 1 and 5'); 453 + describe('single_choice / dropdown validation', () => { 454 + const opts = [{ id: 'o1', label: 'A' }, { id: 'o2', label: 'B' }]; 455 + 456 + it('accepts by option ID', () => { 457 + const q = makeQ({ type: 'single_choice', options: opts }); 458 + expect(validateAnswer(q, 'o1')).toBeNull(); 459 + }); 460 + 461 + it('accepts by option label', () => { 462 + const q = makeQ({ type: 'single_choice', options: opts }); 463 + expect(validateAnswer(q, 'A')).toBeNull(); 464 + }); 465 + 466 + it('rejects invalid choice', () => { 467 + const q = makeQ({ type: 'single_choice', options: opts }); 468 + expect(validateAnswer(q, 'C')).toBe('Invalid choice'); 469 + }); 470 + 471 + it('works for dropdown type too', () => { 472 + const q = makeQ({ type: 'dropdown', options: opts }); 473 + expect(validateAnswer(q, 'B')).toBeNull(); 474 + expect(validateAnswer(q, 'C')).toBe('Invalid choice'); 475 + }); 476 + }); 477 + 478 + describe('multiple_choice validation', () => { 479 + const opts = [ 480 + { id: 'o1', label: 'X' }, 481 + { id: 'o2', label: 'Y' }, 482 + { id: 'o3', label: 'Z' }, 483 + ]; 484 + 485 + it('accepts array of valid choices', () => { 486 + const q = makeQ({ type: 'multiple_choice', options: opts }); 487 + expect(validateAnswer(q, ['o1', 'o3'])).toBeNull(); 488 + }); 489 + 490 + it('accepts array of labels', () => { 491 + const q = makeQ({ type: 'multiple_choice', options: opts }); 492 + expect(validateAnswer(q, ['X', 'Y'])).toBeNull(); 493 + }); 494 + 495 + it('rejects if any choice is invalid', () => { 496 + const q = makeQ({ type: 'multiple_choice', options: opts }); 497 + expect(validateAnswer(q, ['X', 'W'])).toBe('Invalid choice'); 498 + }); 499 + 500 + it('treats non-array as single-element array', () => { 501 + const q = makeQ({ type: 'multiple_choice', options: opts }); 502 + expect(validateAnswer(q, 'X')).toBeNull(); 503 + expect(validateAnswer(q, 'W')).toBe('Invalid choice'); 504 + }); 505 + }); 506 + 507 + describe('rating / scale validation', () => { 508 + it('accepts value within default range (1-5)', () => { 509 + const q = makeQ({ type: 'rating' }); 510 + expect(validateAnswer(q, '3')).toBeNull(); 511 + }); 512 + 513 + it('accepts boundary values', () => { 514 + const q = makeQ({ type: 'rating' }); 515 + expect(validateAnswer(q, '1')).toBeNull(); 516 + expect(validateAnswer(q, '5')).toBeNull(); 517 + }); 518 + 519 + it('rejects below min', () => { 520 + const q = makeQ({ type: 'rating' }); 521 + expect(validateAnswer(q, '0')).toBe('Must be between 1 and 5'); 522 + }); 523 + 524 + it('rejects above max', () => { 525 + const q = makeQ({ type: 'rating' }); 526 + expect(validateAnswer(q, '6')).toBe('Must be between 1 and 5'); 527 + }); 528 + 529 + it('uses custom scale range', () => { 530 + const q = makeQ({ type: 'scale', scaleMin: 0, scaleMax: 10 }); 531 + expect(validateAnswer(q, '0')).toBeNull(); 532 + expect(validateAnswer(q, '10')).toBeNull(); 533 + expect(validateAnswer(q, '-1')).toBe('Must be between 0 and 10'); 534 + expect(validateAnswer(q, '11')).toBe('Must be between 0 and 10'); 535 + }); 536 + 537 + it('rejects non-numeric value', () => { 538 + const q = makeQ({ type: 'rating' }); 539 + expect(validateAnswer(q, 'abc')).toBe('Must be between 1 and 5'); 540 + }); 541 + }); 542 + 543 + describe('custom validation pattern', () => { 544 + it('passes matching pattern', () => { 545 + const q = makeQ({ type: 'short_text', validationPattern: '^\\d{5}$' }); 546 + expect(validateAnswer(q, '12345')).toBeNull(); 547 + }); 548 + 549 + it('rejects non-matching pattern', () => { 550 + const q = makeQ({ type: 'short_text', validationPattern: '^\\d{5}$' }); 551 + expect(validateAnswer(q, 'abc')).toBe('Invalid format'); 552 + }); 553 + 554 + it('skips pattern longer than 200 chars (ReDoS prevention)', () => { 555 + const q = makeQ({ type: 'short_text', validationPattern: 'a'.repeat(201) }); 556 + expect(validateAnswer(q, 'anything')).toBeNull(); 557 + }); 558 + 559 + it('accepts pattern at exactly 200 chars', () => { 560 + const q = makeQ({ type: 'short_text', validationPattern: '^' + '.'.repeat(198) + '$' }); 561 + expect(validateAnswer(q, 'a'.repeat(198))).toBeNull(); 562 + }); 563 + 564 + it('skips invalid regex pattern gracefully', () => { 565 + const q = makeQ({ type: 'short_text', validationPattern: '[invalid' }); 566 + expect(validateAnswer(q, 'anything')).toBeNull(); 567 + }); 568 + }); 569 + 570 + describe('text types (no special validation)', () => { 571 + it('accepts any string for short_text', () => { 572 + const q = makeQ({ type: 'short_text' }); 573 + expect(validateAnswer(q, 'anything goes')).toBeNull(); 574 + }); 575 + 576 + it('accepts any string for long_text', () => { 577 + const q = makeQ({ type: 'long_text' }); 578 + expect(validateAnswer(q, 'long paragraph...')).toBeNull(); 579 + }); 580 + 581 + it('accepts any value for file_upload', () => { 582 + const q = makeQ({ type: 'file_upload' }); 583 + expect(validateAnswer(q, 'file-ref-123')).toBeNull(); 584 + }); 172 585 }); 173 586 }); 587 + 588 + // --- validateSubmission --- 174 589 175 590 describe('validateSubmission', () => { 176 591 it('returns errors for invalid answers', () => { ··· 183 598 ]); 184 599 const errors = validateSubmission(form, answers); 185 600 expect(errors.size).toBe(2); 601 + expect(errors.get(form.questions[0].id)).toBe('This field is required'); 602 + expect(errors.get(form.questions[1].id)).toBe('Invalid email address'); 186 603 }); 187 604 188 605 it('returns empty map for valid submission', () => { ··· 190 607 form = addQuestion(form, 'short_text', 'Name'); 191 608 const answers = new Map<string, unknown>([[form.questions[0].id, 'Alice']]); 192 609 expect(validateSubmission(form, answers).size).toBe(0); 610 + }); 611 + 612 + it('reports required fields with missing answers', () => { 613 + let form = createForm('Test'); 614 + form = addQuestion(form, 'short_text', 'Name', { required: true }); 615 + const answers = new Map<string, unknown>(); 616 + const errors = validateSubmission(form, answers); 617 + expect(errors.size).toBe(1); 618 + expect(errors.get(form.questions[0].id)).toBe('This field is required'); 619 + }); 620 + 621 + it('validates all questions in submission', () => { 622 + let form = createForm('Test'); 623 + form = addQuestion(form, 'number', 'Age'); 624 + form = addQuestion(form, 'email', 'Email'); 625 + form = addQuestion(form, 'short_text', 'Name'); 626 + const answers = new Map<string, unknown>([ 627 + [form.questions[0].id, 'abc'], 628 + [form.questions[1].id, 'valid@email.com'], 629 + [form.questions[2].id, 'Alice'], 630 + ]); 631 + const errors = validateSubmission(form, answers); 632 + expect(errors.size).toBe(1); 633 + expect(errors.has(form.questions[0].id)).toBe(true); 193 634 }); 194 635 }); 195 636 637 + // --- questionCount / requiredCount --- 638 + 196 639 describe('questionCount / requiredCount', () => { 640 + it('returns 0 for empty form', () => { 641 + const form = createForm('Test'); 642 + expect(questionCount(form)).toBe(0); 643 + expect(requiredCount(form)).toBe(0); 644 + }); 645 + 197 646 it('counts questions', () => { 198 647 let form = createForm('Test'); 199 648 form = addQuestion(form, 'short_text', 'A', { required: true }); ··· 204 653 }); 205 654 }); 206 655 656 + // --- duplicateForm --- 657 + 207 658 describe('duplicateForm', () => { 208 659 it('creates copy with new ID', () => { 209 660 let form = createForm('Original'); ··· 212 663 expect(copy.id).not.toBe(form.id); 213 664 expect(copy.title).toBe('Original (Copy)'); 214 665 expect(copy.questions).toEqual(form.questions); 666 + }); 667 + 668 + it('preserves all questions', () => { 669 + let form = createForm('Test'); 670 + form = addQuestion(form, 'short_text', 'Q1'); 671 + form = addQuestion(form, 'number', 'Q2'); 672 + form = addQuestion(form, 'email', 'Q3'); 673 + const copy = duplicateForm(form); 674 + expect(copy.questions).toHaveLength(3); 675 + expect(copy.questions[0].label).toBe('Q1'); 676 + expect(copy.questions[2].label).toBe('Q3'); 677 + }); 678 + 679 + it('has fresh timestamps', () => { 680 + const form = createForm('Test'); 681 + const copy = duplicateForm(form); 682 + expect(copy.createdAt).toBeGreaterThanOrEqual(form.createdAt); 683 + }); 684 + 685 + it('preserves description and targetSheetId', () => { 686 + let form = createForm('Test', 'A description'); 687 + form = setTargetSheet(form, 'sheet-abc'); 688 + const copy = duplicateForm(form); 689 + expect(copy.description).toBe('A description'); 690 + expect(copy.targetSheetId).toBe('sheet-abc'); 215 691 }); 216 692 });
+351
tests/server-validation.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { 3 + isValidDocId, 4 + RateLimiter, 5 + isValidShareMode, 6 + isValidDocType, 7 + isValidMimeType, 8 + filterMetadata, 9 + sanitizeAiRequest, 10 + } from '../server/validation.js'; 11 + 12 + // --- isValidDocId --- 13 + 14 + describe('isValidDocId', () => { 15 + it('accepts UUID without dashes', () => { 16 + expect(isValidDocId('a1b2c3d4e5f6789012345678abcdef01')).toBe(true); 17 + }); 18 + 19 + it('accepts UUID with dashes', () => { 20 + expect(isValidDocId('a1b2c3d4-e5f6-7890-1234-5678abcdef01')).toBe(true); 21 + }); 22 + 23 + it('accepts alphanumeric string', () => { 24 + expect(isValidDocId('myDocument123')).toBe(true); 25 + }); 26 + 27 + it('accepts underscores', () => { 28 + expect(isValidDocId('my_doc_id')).toBe(true); 29 + }); 30 + 31 + it('accepts hyphens', () => { 32 + expect(isValidDocId('my-doc-id')).toBe(true); 33 + }); 34 + 35 + it('accepts single character', () => { 36 + expect(isValidDocId('a')).toBe(true); 37 + }); 38 + 39 + it('rejects empty string', () => { 40 + expect(isValidDocId('')).toBe(false); 41 + }); 42 + 43 + it('rejects string over 100 characters', () => { 44 + expect(isValidDocId('a'.repeat(101))).toBe(false); 45 + }); 46 + 47 + it('accepts string at exactly 100 characters', () => { 48 + expect(isValidDocId('a'.repeat(100))).toBe(true); 49 + }); 50 + 51 + it('rejects spaces', () => { 52 + expect(isValidDocId('my doc')).toBe(false); 53 + }); 54 + 55 + it('rejects dots', () => { 56 + expect(isValidDocId('doc.txt')).toBe(false); 57 + }); 58 + 59 + it('rejects path traversal', () => { 60 + expect(isValidDocId('../etc/passwd')).toBe(false); 61 + }); 62 + 63 + it('rejects special characters', () => { 64 + expect(isValidDocId('doc<script>')).toBe(false); 65 + expect(isValidDocId('doc;drop table')).toBe(false); 66 + expect(isValidDocId("doc'OR'1'='1")).toBe(false); 67 + }); 68 + 69 + it('rejects non-string types', () => { 70 + expect(isValidDocId(null as unknown as string)).toBe(false); 71 + expect(isValidDocId(undefined as unknown as string)).toBe(false); 72 + expect(isValidDocId(123 as unknown as string)).toBe(false); 73 + }); 74 + }); 75 + 76 + // --- RateLimiter --- 77 + 78 + describe('RateLimiter', () => { 79 + let limiter: RateLimiter; 80 + 81 + beforeEach(() => { 82 + limiter = new RateLimiter(); 83 + }); 84 + 85 + it('allows requests under the limit', () => { 86 + expect(limiter.check('user1', 3, 60000)).toBe(true); 87 + expect(limiter.check('user1', 3, 60000)).toBe(true); 88 + expect(limiter.check('user1', 3, 60000)).toBe(true); 89 + }); 90 + 91 + it('blocks requests over the limit', () => { 92 + limiter.check('user1', 2, 60000); 93 + limiter.check('user1', 2, 60000); 94 + expect(limiter.check('user1', 2, 60000)).toBe(false); 95 + }); 96 + 97 + it('tracks different keys independently', () => { 98 + limiter.check('user1', 1, 60000); 99 + expect(limiter.check('user1', 1, 60000)).toBe(false); 100 + expect(limiter.check('user2', 1, 60000)).toBe(true); 101 + }); 102 + 103 + it('resets after window expires', () => { 104 + // Use a very short window 105 + limiter.check('user1', 1, 1); // 1ms window 106 + // Wait for window to expire 107 + const start = Date.now(); 108 + while (Date.now() - start < 5) { /* spin */ } 109 + expect(limiter.check('user1', 1, 1)).toBe(true); 110 + }); 111 + 112 + it('cleanup removes expired entries', () => { 113 + limiter.check('user1', 1, 1); // 1ms window 114 + const start = Date.now(); 115 + while (Date.now() - start < 5) { /* spin */ } 116 + limiter.cleanup(); 117 + // After cleanup, a fresh check should succeed 118 + expect(limiter.check('user1', 1, 60000)).toBe(true); 119 + }); 120 + 121 + it('clear removes all entries', () => { 122 + limiter.check('user1', 1, 60000); 123 + expect(limiter.check('user1', 1, 60000)).toBe(false); 124 + limiter.clear(); 125 + expect(limiter.check('user1', 1, 60000)).toBe(true); 126 + }); 127 + 128 + it('allows limit of 1', () => { 129 + expect(limiter.check('key', 1, 60000)).toBe(true); 130 + expect(limiter.check('key', 1, 60000)).toBe(false); 131 + }); 132 + 133 + it('handles high limits', () => { 134 + for (let i = 0; i < 100; i++) { 135 + expect(limiter.check('key', 100, 60000)).toBe(true); 136 + } 137 + expect(limiter.check('key', 100, 60000)).toBe(false); 138 + }); 139 + }); 140 + 141 + // --- isValidShareMode --- 142 + 143 + describe('isValidShareMode', () => { 144 + it('accepts "edit"', () => { 145 + expect(isValidShareMode('edit')).toBe(true); 146 + }); 147 + 148 + it('accepts "view"', () => { 149 + expect(isValidShareMode('view')).toBe(true); 150 + }); 151 + 152 + it('rejects other strings', () => { 153 + expect(isValidShareMode('admin')).toBe(false); 154 + expect(isValidShareMode('write')).toBe(false); 155 + expect(isValidShareMode('')).toBe(false); 156 + }); 157 + 158 + it('rejects non-strings', () => { 159 + expect(isValidShareMode(null)).toBe(false); 160 + expect(isValidShareMode(undefined)).toBe(false); 161 + expect(isValidShareMode(1)).toBe(false); 162 + expect(isValidShareMode(true)).toBe(false); 163 + }); 164 + }); 165 + 166 + // --- isValidDocType --- 167 + 168 + describe('isValidDocType', () => { 169 + it('accepts all valid types', () => { 170 + expect(isValidDocType('doc')).toBe(true); 171 + expect(isValidDocType('sheet')).toBe(true); 172 + expect(isValidDocType('form')).toBe(true); 173 + expect(isValidDocType('slide')).toBe(true); 174 + expect(isValidDocType('diagram')).toBe(true); 175 + }); 176 + 177 + it('rejects invalid types', () => { 178 + expect(isValidDocType('spreadsheet')).toBe(false); 179 + expect(isValidDocType('text')).toBe(false); 180 + expect(isValidDocType('')).toBe(false); 181 + }); 182 + 183 + it('rejects non-strings', () => { 184 + expect(isValidDocType(null)).toBe(false); 185 + expect(isValidDocType(undefined)).toBe(false); 186 + expect(isValidDocType(42)).toBe(false); 187 + }); 188 + }); 189 + 190 + // --- isValidMimeType --- 191 + 192 + describe('isValidMimeType', () => { 193 + it('accepts standard MIME types', () => { 194 + expect(isValidMimeType('application/json')).toBe(true); 195 + expect(isValidMimeType('text/html')).toBe(true); 196 + expect(isValidMimeType('image/png')).toBe(true); 197 + expect(isValidMimeType('application/octet-stream')).toBe(true); 198 + }); 199 + 200 + it('accepts MIME types with dots and plus', () => { 201 + expect(isValidMimeType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')).toBe(true); 202 + expect(isValidMimeType('application/ld+json')).toBe(true); 203 + expect(isValidMimeType('image/svg+xml')).toBe(true); 204 + }); 205 + 206 + it('rejects MIME types without slash', () => { 207 + expect(isValidMimeType('texthtml')).toBe(false); 208 + expect(isValidMimeType('justtext')).toBe(false); 209 + }); 210 + 211 + it('rejects empty string', () => { 212 + expect(isValidMimeType('')).toBe(false); 213 + }); 214 + 215 + it('rejects strings with spaces', () => { 216 + expect(isValidMimeType('text / html')).toBe(false); 217 + }); 218 + 219 + it('rejects injection attempts', () => { 220 + expect(isValidMimeType('text/html; charset=utf-8')).toBe(false); 221 + expect(isValidMimeType('text/html\nX-Injected: true')).toBe(false); 222 + }); 223 + }); 224 + 225 + // --- filterMetadata --- 226 + 227 + describe('filterMetadata', () => { 228 + it('extracts allowed keys', () => { 229 + const result = filterMetadata({ 230 + label: 'v1', 231 + description: 'First version', 232 + starred: true, 233 + color: '#ff0000', 234 + tags: 'important', 235 + }); 236 + expect(result).toEqual({ 237 + label: 'v1', 238 + description: 'First version', 239 + starred: true, 240 + color: '#ff0000', 241 + tags: 'important', 242 + }); 243 + }); 244 + 245 + it('ignores disallowed keys', () => { 246 + const result = filterMetadata({ 247 + label: 'v1', 248 + __proto__: 'evil', 249 + constructor: 'bad', 250 + admin: true, 251 + script: '<script>alert(1)</script>', 252 + }); 253 + expect(result).toEqual({ label: 'v1' }); 254 + expect(result).not.toHaveProperty('admin'); 255 + expect(result).not.toHaveProperty('script'); 256 + }); 257 + 258 + it('returns empty object for non-object input', () => { 259 + expect(filterMetadata(null)).toEqual({}); 260 + expect(filterMetadata(undefined)).toEqual({}); 261 + expect(filterMetadata('string')).toEqual({}); 262 + expect(filterMetadata(42)).toEqual({}); 263 + }); 264 + 265 + it('returns empty object for array input', () => { 266 + expect(filterMetadata([1, 2, 3])).toEqual({}); 267 + }); 268 + 269 + it('returns empty object for empty object', () => { 270 + expect(filterMetadata({})).toEqual({}); 271 + }); 272 + 273 + it('preserves falsy values for allowed keys', () => { 274 + const result = filterMetadata({ starred: false, label: '', color: null }); 275 + expect(result).toEqual({ starred: false, label: '', color: null }); 276 + }); 277 + }); 278 + 279 + // --- sanitizeAiRequest --- 280 + 281 + describe('sanitizeAiRequest', () => { 282 + it('passes through valid request with messages', () => { 283 + const result = sanitizeAiRequest({ 284 + model: 'gpt-4', 285 + messages: [{ role: 'user', content: 'Hello' }], 286 + temperature: 0.7, 287 + }); 288 + expect(result).toEqual({ 289 + model: 'gpt-4', 290 + messages: [{ role: 'user', content: 'Hello' }], 291 + temperature: 0.7, 292 + }); 293 + }); 294 + 295 + it('strips disallowed fields', () => { 296 + const result = sanitizeAiRequest({ 297 + messages: [{ role: 'user', content: 'Hello' }], 298 + api_key: 'sk-secret', 299 + headers: { Authorization: 'Bearer token' }, 300 + url: 'https://evil.com', 301 + }); 302 + expect(result).toEqual({ 303 + messages: [{ role: 'user', content: 'Hello' }], 304 + }); 305 + expect(result).not.toHaveProperty('api_key'); 306 + expect(result).not.toHaveProperty('headers'); 307 + expect(result).not.toHaveProperty('url'); 308 + }); 309 + 310 + it('includes all allowed fields', () => { 311 + const body = { 312 + model: 'claude-3', 313 + messages: [{ role: 'user', content: 'Hi' }], 314 + temperature: 0.5, 315 + max_tokens: 100, 316 + stream: true, 317 + top_p: 0.9, 318 + stop: ['\n'], 319 + presence_penalty: 0.1, 320 + frequency_penalty: 0.2, 321 + }; 322 + const result = sanitizeAiRequest(body); 323 + expect(result).toEqual(body); 324 + }); 325 + 326 + it('returns null when messages is missing', () => { 327 + expect(sanitizeAiRequest({ model: 'gpt-4' })).toBeNull(); 328 + }); 329 + 330 + it('returns null when messages is not an array', () => { 331 + expect(sanitizeAiRequest({ messages: 'not an array' })).toBeNull(); 332 + expect(sanitizeAiRequest({ messages: { role: 'user' } })).toBeNull(); 333 + }); 334 + 335 + it('returns null for non-object input', () => { 336 + expect(sanitizeAiRequest(null)).toBeNull(); 337 + expect(sanitizeAiRequest(undefined)).toBeNull(); 338 + expect(sanitizeAiRequest('string')).toBeNull(); 339 + expect(sanitizeAiRequest(42)).toBeNull(); 340 + }); 341 + 342 + it('returns null for array input', () => { 343 + expect(sanitizeAiRequest([{ messages: [] }])).toBeNull(); 344 + }); 345 + 346 + it('passes through empty messages array (upstream validates content)', () => { 347 + // Empty array passes the Array.isArray check — upstream API will reject 348 + const result = sanitizeAiRequest({ messages: [] }); 349 + expect(result).toEqual({ messages: [] }); 350 + }); 351 + });