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 'fix: server authorization checks and test foundation (129 tests)' (#327) from fix/server-security-and-tests into main

scott 6bee101c 08778cf2

+1993 -31
+1
server/db.ts
··· 190 190 listBlobs: db.prepare('SELECT id, document_id, file_name, mime_type, size, created_at FROM blobs WHERE document_id = ? ORDER BY created_at DESC'), 191 191 deleteBlob: db.prepare('DELETE FROM blobs WHERE id = ?'), 192 192 deleteBlobsForDoc: db.prepare('DELETE FROM blobs WHERE document_id = ?'), 193 + deleteVersionsForDoc: db.prepare('DELETE FROM versions WHERE document_id = ?'), 193 194 };
+1 -1
server/index.ts
··· 147 147 } 148 148 } 149 149 150 - const wss = new WebSocketServer({ noServer: true }); 150 + const wss = new WebSocketServer({ noServer: true, maxPayload: 5 * 1024 * 1024 }); // 5MB max message size 151 151 152 152 function handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void { 153 153 const url = new URL(request.url || '', 'http://localhost');
+33 -18
server/routes/api-v1.ts
··· 7 7 8 8 const router = Router(); 9 9 10 + const VALID_TYPES = ['doc', 'sheet', 'form', 'slide', 'diagram', 'calendar'] as const; 11 + 12 + // Pre-built prepared statements for the two query shapes (with and without type filter) 13 + const listAllStmt = db.prepare( 14 + 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT ? OFFSET ?' 15 + ); 16 + const listByTypeStmt = db.prepare( 17 + 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE deleted_at IS NULL AND type = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?' 18 + ); 19 + const countStmt = db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL'); 20 + const getOneStmt = db.prepare('SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE id = ?'); 21 + 10 22 // Search documents by name (encrypted names are matched client-side, but 11 23 // the server can filter by type and return metadata for cross-doc linking) 12 24 router.get('/api/v1/documents', (req: Request, res: Response) => { 13 25 const { type, limit: lim, offset: off } = req.query; 14 - const validTypes = ['doc', 'sheet', 'form', 'slide', 'diagram', 'calendar']; 15 - let query = 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE deleted_at IS NULL'; 16 - const params: unknown[] = []; 17 - 18 - if (type && validTypes.includes(type as string)) { 19 - query += ' AND type = ?'; 20 - params.push(type); 21 - } 22 - 23 - query += ' ORDER BY updated_at DESC'; 24 - 25 26 const limit = Math.min(Math.max(parseInt(lim as string) || 50, 1), 200); 26 27 const offset = Math.max(parseInt(off as string) || 0, 0); 27 - query += ` LIMIT ? OFFSET ?`; 28 - params.push(limit, offset); 28 + 29 + const rows = (type && VALID_TYPES.includes(type as typeof VALID_TYPES[number])) 30 + ? listByTypeStmt.all(type, limit, offset) 31 + : listAllStmt.all(limit, offset); 29 32 30 - const rows = db.prepare(query).all(...params); 31 - const total = (db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL').get() as { count: number }).count; 33 + const total = (countStmt.get() as { count: number }).count; 32 34 res.json({ data: rows, total, limit, offset }); 33 35 }); 34 36 35 37 // Get single document metadata 36 38 router.get('/api/v1/documents/:id', (req: Request<{ id: string }>, res: Response) => { 37 - const row = db.prepare('SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE id = ?').get(req.params.id); 39 + const row = getOneStmt.get(req.params.id); 38 40 if (!row) return res.status(404).json({ error: 'Not found' }); 39 41 res.json(row); 40 42 }); 41 43 42 44 // Batch resolve document metadata (for cross-doc embeds / wiki links) 45 + // Uses a cached map of prepared statements keyed by batch size to avoid 46 + // re-preparing on every request while keeping fully parameterized queries. 47 + const resolveStmtCache = new Map<number, ReturnType<typeof db.prepare>>(); 48 + 49 + function getResolveStmt(count: number) { 50 + let stmt = resolveStmtCache.get(count); 51 + if (!stmt) { 52 + const placeholders = Array.from({ length: count }, () => '?').join(','); 53 + stmt = db.prepare(`SELECT id, type, name_encrypted, updated_at FROM documents WHERE id IN (${placeholders}) AND deleted_at IS NULL`); 54 + resolveStmtCache.set(count, stmt); 55 + } 56 + return stmt; 57 + } 58 + 43 59 router.post('/api/v1/documents/resolve', (req: Request<Record<string, string>, unknown, { ids: string[] }>, res: Response) => { 44 60 const { ids } = req.body; 45 61 if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' }); 46 62 const capped = ids.slice(0, 50).filter(id => typeof id === 'string' && id.length > 0); 47 63 if (capped.length === 0) return res.status(400).json({ error: 'ids must be non-empty strings' }); 48 - const placeholders = capped.map(() => '?').join(','); 49 - const rows = db.prepare(`SELECT id, type, name_encrypted, updated_at FROM documents WHERE id IN (${placeholders}) AND deleted_at IS NULL`).all(...capped); 64 + const rows = getResolveStmt(capped.length).all(capped); 50 65 res.json({ data: rows }); 51 66 }); 52 67
+20 -2
server/routes/blobs.ts
··· 7 7 import { randomUUID } from 'crypto'; 8 8 import { stmts } from '../db.js'; 9 9 import { isValidMimeType } from '../validation.js'; 10 + import type { TailscaleUser, DocumentListRow } from '../types.js'; 10 11 11 12 const router = Router(); 12 13 13 14 const BLOB_MAX_SIZE = 10 * 1024 * 1024; // 10MB per blob 14 15 15 - router.post('/api/blobs', express.raw({ limit: '10mb', type: '*/*' }), (req: Request<Record<string, string>, unknown, Buffer>, res: Response) => { 16 + router.post('/api/blobs', express.raw({ limit: '10mb', type: '*/*' }), (req: Request<Record<string, string>, unknown, Buffer> & { tsUser?: TailscaleUser | null }, res: Response) => { 16 17 const docId = req.headers['x-document-id'] as string; 17 18 const fileName = (req.headers['x-file-name'] as string || 'file').slice(0, 255); 18 19 const rawMime = (req.headers['x-mime-type'] as string || 'application/octet-stream').slice(0, 255); 19 20 const mimeType = isValidMimeType(rawMime) ? rawMime : 'application/octet-stream'; 20 21 if (!docId) return res.status(400).json({ error: 'x-document-id header required' }); 22 + 23 + // Owner check: allow owner, anonymous docs, and shared-edit docs 24 + const doc = stmts.getOne.get(docId) as DocumentListRow | undefined; 25 + if (doc?.owner && req.tsUser?.login !== doc.owner && doc.share_mode !== 'edit') { 26 + return res.status(403).json({ error: 'Only the document owner can upload blobs' }); 27 + } 28 + 21 29 const data = req.body; 22 30 if (!data || !data.length) return res.status(400).json({ error: 'No data' }); 23 31 if (data.length > BLOB_MAX_SIZE) return res.status(413).json({ error: 'Blob too large (max 10MB)' }); ··· 41 49 res.json(rows); 42 50 }); 43 51 44 - router.delete('/api/blobs/:id', (req: Request<{ id: string }>, res: Response) => { 52 + router.delete('/api/blobs/:id', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 53 + const blob = stmts.getBlob.get(req.params.id) as { document_id: string } | undefined; 54 + if (!blob) { res.status(404).json({ error: 'Not found' }); return; } 55 + 56 + // Owner check: look up the parent document 57 + const doc = stmts.getOne.get(blob.document_id) as DocumentListRow | undefined; 58 + if (doc?.owner && req.tsUser?.login !== doc.owner) { 59 + res.status(403).json({ error: 'Only the document owner can delete blobs' }); 60 + return; 61 + } 62 + 45 63 stmts.deleteBlob.run(req.params.id); 46 64 res.json({ ok: true }); 47 65 });
+40 -8
server/routes/documents.ts
··· 117 117 res.status(403).json({ error: 'Only the document owner can delete' }); 118 118 return; 119 119 } 120 - stmts.deleteDoc.run(req.params.id); 120 + // Issue #502: cascade delete versions and blobs in a transaction 121 + db.transaction(() => { 122 + stmts.deleteVersionsForDoc.run(req.params.id); 123 + stmts.deleteBlobsForDoc.run(req.params.id); 124 + stmts.deleteDoc.run(req.params.id); 125 + })(); 121 126 res.json({ ok: true }); 122 127 }); 123 128 124 - router.put('/api/documents/:id/trash', (req: Request<{ id: string }>, res: Response) => { 125 - const doc = stmts.getOne.get(req.params.id) as DocumentRow | undefined; 129 + router.put('/api/documents/:id/trash', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 130 + const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 126 131 if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 132 + if (doc.owner && req.tsUser?.login !== doc.owner) { 133 + res.status(403).json({ error: 'Only the document owner can trash' }); 134 + return; 135 + } 127 136 stmts.trashDoc.run(req.params.id); 128 137 res.json({ ok: true }); 129 138 }); 130 139 131 - router.put('/api/documents/:id/restore', (req: Request<{ id: string }>, res: Response) => { 132 - const doc = stmts.getOne.get(req.params.id) as DocumentRow | undefined; 140 + router.put('/api/documents/:id/restore', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 141 + const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 133 142 if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 143 + if (doc.owner && req.tsUser?.login !== doc.owner) { 144 + res.status(403).json({ error: 'Only the document owner can restore' }); 145 + return; 146 + } 134 147 stmts.restoreDoc.run(req.params.id); 135 148 res.json({ ok: true }); 136 149 }); 137 150 138 - router.put('/api/documents/:id/name', (req: Request<{ id: string }, unknown, UpdateNameBody>, res: Response) => { 151 + router.put('/api/documents/:id/name', (req: Request<{ id: string }, unknown, UpdateNameBody> & { tsUser?: TailscaleUser | null }, res: Response) => { 139 152 const { name_encrypted } = req.body; 140 153 if (name_encrypted != null && typeof name_encrypted !== 'string') { 141 154 return res.status(400).json({ error: 'name_encrypted must be a string or null' }); ··· 143 156 if (typeof name_encrypted === 'string' && name_encrypted.length > 10000) { 144 157 return res.status(400).json({ error: 'name_encrypted too long' }); 145 158 } 159 + const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 160 + if (doc?.owner && req.tsUser?.login !== doc.owner) { 161 + return res.status(403).json({ error: 'Only the document owner can rename' }); 162 + } 146 163 stmts.putName.run(name_encrypted, req.params.id); 147 164 res.json({ ok: true }); 148 165 }); 149 166 150 - router.put('/api/documents/:id/tags', (req: Request<{ id: string }, unknown, { tags: string }>, res: Response) => { 167 + router.put('/api/documents/:id/tags', (req: Request<{ id: string }, unknown, { tags: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 151 168 const { tags } = req.body; 152 169 if (tags != null && typeof tags !== 'string') { 153 170 return res.status(400).json({ error: 'tags must be a string or null' }); 154 171 } 155 172 if (typeof tags === 'string' && tags.length > 10000) { 156 173 return res.status(400).json({ error: 'tags too long' }); 174 + } 175 + const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 176 + if (doc?.owner && req.tsUser?.login !== doc.owner) { 177 + return res.status(403).json({ error: 'Only the document owner can update tags' }); 157 178 } 158 179 stmts.putTags.run(tags ?? null, req.params.id); 159 180 res.json({ ok: true }); ··· 170 191 res.status(400).json({ error: 'Empty or missing snapshot body' }); 171 192 return; 172 193 } 194 + 195 + // Owner check: allow owner, anonymous docs, and shared-edit docs 196 + const existing = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 197 + if (existing?.owner && req.tsUser?.login !== existing.owner && existing.share_mode !== 'edit') { 198 + res.status(403).json({ error: 'Only the document owner can save snapshots' }); 199 + return; 200 + } 201 + 173 202 try { 174 203 const result = stmts.putSnapshot.run(req.body, req.params.id); 175 204 if (result.changes === 0) { 205 + // Issue #499: use actual doc type from header instead of hardcoded 'doc' 206 + const docType = (req.headers['x-document-type'] as string) || 'doc'; 207 + const validType = isValidDocType(docType) ? docType : 'doc'; 176 208 db.transaction(() => { 177 - db.prepare("INSERT OR IGNORE INTO documents (id, type, name_encrypted) VALUES (?, 'doc', NULL)").run(req.params.id); 209 + db.prepare("INSERT OR IGNORE INTO documents (id, type, name_encrypted) VALUES (?, ?, NULL)").run(req.params.id, validType); 178 210 stmts.putSnapshot.run(req.body, req.params.id); 179 211 })(); 180 212 }
+10 -2
server/routes/versions.ts
··· 7 7 import { randomUUID } from 'crypto'; 8 8 import { db, stmts, MAX_VERSIONS_PER_DOC } from '../db.js'; 9 9 import { filterMetadata } from '../validation.js'; 10 - import type { VersionRow, VersionSnapshotRow, VersionCountRow } from '../types.js'; 10 + import type { TailscaleUser, DocumentListRow, VersionRow, VersionSnapshotRow, VersionCountRow } from '../types.js'; 11 11 12 12 const router = Router(); 13 13 ··· 19 19 }))); 20 20 }); 21 21 22 - router.post('/api/documents/:id/versions', express.raw({ limit: '50mb', type: '*/*' }), (req: Request<{ id: string }>, res: Response) => { 22 + router.post('/api/documents/:id/versions', express.raw({ limit: '50mb', type: '*/*' }), (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 23 23 const docId = req.params.id; 24 + 25 + // Owner check: allow owner, anonymous docs, and shared-edit docs 26 + const doc = stmts.getOne.get(docId) as DocumentListRow | undefined; 27 + if (doc?.owner && req.tsUser?.login !== doc.owner && doc.share_mode !== 'edit') { 28 + res.status(403).json({ error: 'Only the document owner can create versions' }); 29 + return; 30 + } 31 + 24 32 const id = randomUUID(); 25 33 const metadata = req.headers['x-version-metadata'] as string | undefined ?? null; 26 34 stmts.insertVersion.run(id, docId, req.body, metadata);
+1
server/types.ts
··· 113 113 listBlobs: Statement; 114 114 deleteBlob: Statement; 115 115 deleteBlobsForDoc: Statement; 116 + deleteVersionsForDoc: Statement; 116 117 }
+542
tests/server/db.test.ts
··· 1 + /** 2 + * Database operations tests. 3 + * Tests document CRUD, version management, blob storage, and cascade deletion 4 + * using an in-memory SQLite database that mirrors the production schema. 5 + */ 6 + 7 + import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 8 + import Database, { type Database as DatabaseType, type Statement } from 'better-sqlite3'; 9 + import { randomUUID } from 'crypto'; 10 + 11 + // In-memory database with same schema as production 12 + let db: DatabaseType; 13 + 14 + // Typed statement map matching production PreparedStatements 15 + interface TestStatements { 16 + insert: Statement; 17 + insertWithOwner: Statement; 18 + getOne: Statement; 19 + getAll: Statement; 20 + getTrash: Statement; 21 + getSnapshot: Statement; 22 + putSnapshot: Statement; 23 + putName: Statement; 24 + trashDoc: Statement; 25 + restoreDoc: Statement; 26 + deleteDoc: Statement; 27 + insertVersion: Statement; 28 + getVersions: Statement; 29 + getVersionSnapshot: Statement; 30 + countVersions: Statement; 31 + updateShare: Statement; 32 + putTags: Statement; 33 + upsertUser: Statement; 34 + getUser: Statement; 35 + getAllUsers: Statement; 36 + getKeys: Statement; 37 + putKeys: Statement; 38 + insertBlob: Statement; 39 + getBlob: Statement; 40 + listBlobs: Statement; 41 + deleteBlob: Statement; 42 + deleteBlobsForDoc: Statement; 43 + deleteVersionsForDoc: Statement; 44 + } 45 + 46 + let stmts: TestStatements; 47 + 48 + beforeAll(() => { 49 + db = new Database(':memory:'); 50 + db.pragma('journal_mode = WAL'); 51 + 52 + db.exec(` 53 + CREATE TABLE documents ( 54 + id TEXT PRIMARY KEY, 55 + type TEXT NOT NULL CHECK(type IN ('doc','sheet','form','slide','diagram','calendar')), 56 + name_encrypted TEXT, 57 + snapshot BLOB, 58 + share_mode TEXT DEFAULT 'edit', 59 + expires_at TEXT, 60 + deleted_at TEXT, 61 + tags TEXT, 62 + owner TEXT, 63 + created_at TEXT DEFAULT (datetime('now')), 64 + updated_at TEXT DEFAULT (datetime('now')) 65 + ) 66 + `); 67 + 68 + db.exec(` 69 + CREATE TABLE versions ( 70 + id TEXT PRIMARY KEY, 71 + document_id TEXT NOT NULL, 72 + snapshot BLOB NOT NULL, 73 + created_at TEXT DEFAULT (datetime('now')), 74 + metadata TEXT 75 + ) 76 + `); 77 + 78 + db.exec(` 79 + CREATE TABLE users ( 80 + login TEXT PRIMARY KEY, 81 + name TEXT NOT NULL, 82 + profile_pic TEXT, 83 + first_seen TEXT DEFAULT (datetime('now')), 84 + last_seen TEXT DEFAULT (datetime('now')) 85 + ) 86 + `); 87 + 88 + db.exec(` 89 + CREATE TABLE user_keys ( 90 + login TEXT PRIMARY KEY REFERENCES users(login), 91 + keys_json TEXT NOT NULL DEFAULT '{}', 92 + updated_at TEXT DEFAULT (datetime('now')) 93 + ) 94 + `); 95 + 96 + db.exec(` 97 + CREATE TABLE blobs ( 98 + id TEXT PRIMARY KEY, 99 + document_id TEXT NOT NULL, 100 + file_name TEXT NOT NULL, 101 + mime_type TEXT NOT NULL, 102 + size INTEGER NOT NULL, 103 + data BLOB NOT NULL, 104 + created_at TEXT DEFAULT (datetime('now')) 105 + ) 106 + `); 107 + 108 + stmts = { 109 + insert: db.prepare('INSERT INTO documents (id, type, name_encrypted) VALUES (?, ?, ?)'), 110 + insertWithOwner: db.prepare('INSERT INTO documents (id, type, name_encrypted, owner) VALUES (?, ?, ?, ?)'), 111 + getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE id = ?'), 112 + getAll: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NULL ORDER BY updated_at DESC'), 113 + getTrash: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC'), 114 + getSnapshot: db.prepare('SELECT snapshot, expires_at FROM documents WHERE id = ?'), 115 + putSnapshot: db.prepare("UPDATE documents SET snapshot = ?, updated_at = datetime('now') WHERE id = ?"), 116 + putName: db.prepare("UPDATE documents SET name_encrypted = ?, updated_at = datetime('now') WHERE id = ?"), 117 + trashDoc: db.prepare("UPDATE documents SET deleted_at = datetime('now') WHERE id = ? AND deleted_at IS NULL"), 118 + restoreDoc: db.prepare("UPDATE documents SET deleted_at = NULL WHERE id = ?"), 119 + deleteDoc: db.prepare('DELETE FROM documents WHERE id = ?'), 120 + insertVersion: db.prepare('INSERT INTO versions (id, document_id, snapshot, metadata) VALUES (?, ?, ?, ?)'), 121 + getVersions: db.prepare('SELECT id, document_id, created_at, metadata FROM versions WHERE document_id = ? ORDER BY rowid DESC LIMIT 50'), 122 + getVersionSnapshot: db.prepare('SELECT snapshot FROM versions WHERE id = ? AND document_id = ?'), 123 + countVersions: db.prepare('SELECT COUNT(*) as count FROM versions WHERE document_id = ?'), 124 + updateShare: db.prepare("UPDATE documents SET share_mode = ?, expires_at = ?, updated_at = datetime('now') WHERE id = ?"), 125 + putTags: db.prepare("UPDATE documents SET tags = ?, updated_at = datetime('now') WHERE id = ?"), 126 + upsertUser: db.prepare(`INSERT INTO users (login, name, profile_pic) VALUES (?, ?, ?) 127 + ON CONFLICT(login) DO UPDATE SET name=excluded.name, profile_pic=excluded.profile_pic, last_seen=datetime('now')`), 128 + getUser: db.prepare('SELECT * FROM users WHERE login = ?'), 129 + getAllUsers: db.prepare('SELECT login, name, profile_pic FROM users ORDER BY last_seen DESC'), 130 + getKeys: db.prepare('SELECT keys_json FROM user_keys WHERE login = ?'), 131 + putKeys: db.prepare(`INSERT INTO user_keys (login, keys_json, updated_at) VALUES (?, ?, datetime('now')) 132 + ON CONFLICT(login) DO UPDATE SET keys_json = excluded.keys_json, updated_at = datetime('now')`), 133 + insertBlob: db.prepare('INSERT INTO blobs (id, document_id, file_name, mime_type, size, data) VALUES (?, ?, ?, ?, ?, ?)'), 134 + getBlob: db.prepare('SELECT id, document_id, file_name, mime_type, size, data, created_at FROM blobs WHERE id = ?'), 135 + listBlobs: db.prepare('SELECT id, document_id, file_name, mime_type, size, created_at FROM blobs WHERE document_id = ? ORDER BY created_at DESC'), 136 + deleteBlob: db.prepare('DELETE FROM blobs WHERE id = ?'), 137 + deleteBlobsForDoc: db.prepare('DELETE FROM blobs WHERE document_id = ?'), 138 + deleteVersionsForDoc: db.prepare('DELETE FROM versions WHERE document_id = ?'), 139 + }; 140 + }); 141 + 142 + afterAll(() => { 143 + db?.close(); 144 + }); 145 + 146 + // --- Document CRUD --- 147 + 148 + describe('Document CRUD', () => { 149 + it('inserts a document without owner', () => { 150 + const id = randomUUID(); 151 + stmts.insert.run(id, 'doc', 'encrypted-name'); 152 + const doc = stmts.getOne.get(id) as Record<string, unknown>; 153 + expect(doc).toBeDefined(); 154 + expect(doc.id).toBe(id); 155 + expect(doc.type).toBe('doc'); 156 + expect(doc.name_encrypted).toBe('encrypted-name'); 157 + expect(doc.owner).toBeNull(); 158 + }); 159 + 160 + it('inserts a document with owner', () => { 161 + const id = randomUUID(); 162 + stmts.insertWithOwner.run(id, 'sheet', 'encrypted', 'scott@tailnet'); 163 + const doc = stmts.getOne.get(id) as Record<string, unknown>; 164 + expect(doc.owner).toBe('scott@tailnet'); 165 + expect(doc.type).toBe('sheet'); 166 + }); 167 + 168 + it('inserts all valid document types', () => { 169 + for (const type of ['doc', 'sheet', 'form', 'slide', 'diagram', 'calendar']) { 170 + const id = randomUUID(); 171 + stmts.insert.run(id, type, null); 172 + const doc = stmts.getOne.get(id) as Record<string, unknown>; 173 + expect(doc.type).toBe(type); 174 + } 175 + }); 176 + 177 + it('rejects invalid document types via CHECK constraint', () => { 178 + expect(() => stmts.insert.run(randomUUID(), 'invalid', null)).toThrow(); 179 + }); 180 + 181 + it('lists documents excluding trashed ones', () => { 182 + const id1 = randomUUID(); 183 + const id2 = randomUUID(); 184 + stmts.insert.run(id1, 'doc', null); 185 + stmts.insert.run(id2, 'doc', null); 186 + stmts.trashDoc.run(id2); 187 + 188 + const all = stmts.getAll.all() as Array<Record<string, unknown>>; 189 + const ids = all.map((d) => d.id); 190 + expect(ids).toContain(id1); 191 + expect(ids).not.toContain(id2); 192 + }); 193 + 194 + it('lists trashed documents', () => { 195 + const id = randomUUID(); 196 + stmts.insert.run(id, 'doc', null); 197 + stmts.trashDoc.run(id); 198 + 199 + const trash = stmts.getTrash.all() as Array<Record<string, unknown>>; 200 + const ids = trash.map((d) => d.id); 201 + expect(ids).toContain(id); 202 + }); 203 + 204 + it('returns undefined for non-existent document', () => { 205 + const doc = stmts.getOne.get('nonexistent-id'); 206 + expect(doc).toBeUndefined(); 207 + }); 208 + 209 + it('updates document name', () => { 210 + const id = randomUUID(); 211 + stmts.insert.run(id, 'doc', 'old-name'); 212 + stmts.putName.run('new-name', id); 213 + const doc = stmts.getOne.get(id) as Record<string, unknown>; 214 + expect(doc.name_encrypted).toBe('new-name'); 215 + }); 216 + 217 + it('updates document tags', () => { 218 + const id = randomUUID(); 219 + stmts.insert.run(id, 'doc', null); 220 + stmts.putTags.run('tag1,tag2', id); 221 + const doc = stmts.getOne.get(id) as Record<string, unknown>; 222 + expect(doc.tags).toBe('tag1,tag2'); 223 + }); 224 + 225 + it('trashes and restores a document', () => { 226 + const id = randomUUID(); 227 + stmts.insert.run(id, 'doc', null); 228 + 229 + stmts.trashDoc.run(id); 230 + let doc = stmts.getOne.get(id) as Record<string, unknown>; 231 + expect(doc.deleted_at).not.toBeNull(); 232 + 233 + stmts.restoreDoc.run(id); 234 + doc = stmts.getOne.get(id) as Record<string, unknown>; 235 + expect(doc.deleted_at).toBeNull(); 236 + }); 237 + 238 + it('permanently deletes a document', () => { 239 + const id = randomUUID(); 240 + stmts.insert.run(id, 'doc', null); 241 + stmts.deleteDoc.run(id); 242 + expect(stmts.getOne.get(id)).toBeUndefined(); 243 + }); 244 + 245 + it('updates share settings', () => { 246 + const id = randomUUID(); 247 + stmts.insert.run(id, 'doc', null); 248 + stmts.updateShare.run('view', '2026-12-31T00:00:00Z', id); 249 + const doc = stmts.getOne.get(id) as Record<string, unknown>; 250 + expect(doc.share_mode).toBe('view'); 251 + expect(doc.expires_at).toBe('2026-12-31T00:00:00Z'); 252 + }); 253 + }); 254 + 255 + // --- Snapshot storage --- 256 + 257 + describe('Snapshot storage', () => { 258 + it('stores and retrieves a binary snapshot', () => { 259 + const id = randomUUID(); 260 + stmts.insert.run(id, 'doc', null); 261 + const data = Buffer.from([1, 2, 3, 4, 5]); 262 + stmts.putSnapshot.run(data, id); 263 + 264 + const row = stmts.getSnapshot.get(id) as { snapshot: Buffer }; 265 + expect(Buffer.isBuffer(row.snapshot)).toBe(true); 266 + expect(Array.from(row.snapshot)).toEqual([1, 2, 3, 4, 5]); 267 + }); 268 + 269 + it('overwrites an existing snapshot', () => { 270 + const id = randomUUID(); 271 + stmts.insert.run(id, 'doc', null); 272 + stmts.putSnapshot.run(Buffer.from([1, 2, 3]), id); 273 + stmts.putSnapshot.run(Buffer.from([10, 20, 30]), id); 274 + 275 + const row = stmts.getSnapshot.get(id) as { snapshot: Buffer }; 276 + expect(Array.from(row.snapshot)).toEqual([10, 20, 30]); 277 + }); 278 + 279 + it('returns null snapshot for new document', () => { 280 + const id = randomUUID(); 281 + stmts.insert.run(id, 'doc', null); 282 + const row = stmts.getSnapshot.get(id) as { snapshot: Buffer | null }; 283 + expect(row.snapshot).toBeNull(); 284 + }); 285 + }); 286 + 287 + // --- Version management --- 288 + 289 + describe('Version management', () => { 290 + it('creates and lists versions', () => { 291 + const docId = randomUUID(); 292 + stmts.insert.run(docId, 'doc', null); 293 + 294 + const v1 = randomUUID(); 295 + const v2 = randomUUID(); 296 + stmts.insertVersion.run(v1, docId, Buffer.from('v1-data'), null); 297 + stmts.insertVersion.run(v2, docId, Buffer.from('v2-data'), JSON.stringify({ label: 'v2' })); 298 + 299 + const versions = stmts.getVersions.all(docId) as Array<Record<string, unknown>>; 300 + expect(versions.length).toBe(2); 301 + // Most recent first (ORDER BY rowid DESC) 302 + expect(versions[0]!.id).toBe(v2); 303 + expect(versions[1]!.id).toBe(v1); 304 + }); 305 + 306 + it('retrieves a specific version snapshot', () => { 307 + const docId = randomUUID(); 308 + stmts.insert.run(docId, 'doc', null); 309 + const vId = randomUUID(); 310 + stmts.insertVersion.run(vId, docId, Buffer.from('snapshot-data'), null); 311 + 312 + const row = stmts.getVersionSnapshot.get(vId, docId) as { snapshot: Buffer }; 313 + expect(row.snapshot.toString()).toBe('snapshot-data'); 314 + }); 315 + 316 + it('returns undefined for wrong document_id', () => { 317 + const docId = randomUUID(); 318 + stmts.insert.run(docId, 'doc', null); 319 + const vId = randomUUID(); 320 + stmts.insertVersion.run(vId, docId, Buffer.from('data'), null); 321 + 322 + const row = stmts.getVersionSnapshot.get(vId, 'wrong-doc-id'); 323 + expect(row).toBeUndefined(); 324 + }); 325 + 326 + it('counts versions correctly', () => { 327 + const docId = randomUUID(); 328 + stmts.insert.run(docId, 'doc', null); 329 + 330 + for (let i = 0; i < 5; i++) { 331 + stmts.insertVersion.run(randomUUID(), docId, Buffer.from(`v${i}`), null); 332 + } 333 + 334 + const count = stmts.countVersions.get(docId) as { count: number }; 335 + expect(count.count).toBe(5); 336 + }); 337 + 338 + it('deletes versions for a document', () => { 339 + const docId = randomUUID(); 340 + stmts.insert.run(docId, 'doc', null); 341 + stmts.insertVersion.run(randomUUID(), docId, Buffer.from('v1'), null); 342 + stmts.insertVersion.run(randomUUID(), docId, Buffer.from('v2'), null); 343 + 344 + stmts.deleteVersionsForDoc.run(docId); 345 + const count = stmts.countVersions.get(docId) as { count: number }; 346 + expect(count.count).toBe(0); 347 + }); 348 + }); 349 + 350 + // --- Blob storage --- 351 + 352 + describe('Blob storage', () => { 353 + it('inserts and retrieves a blob', () => { 354 + const docId = randomUUID(); 355 + stmts.insert.run(docId, 'doc', null); 356 + 357 + const blobId = randomUUID(); 358 + const data = Buffer.from('file-content'); 359 + stmts.insertBlob.run(blobId, docId, 'test.txt', 'text/plain', data.length, data); 360 + 361 + const blob = stmts.getBlob.get(blobId) as Record<string, unknown>; 362 + expect(blob).toBeDefined(); 363 + expect(blob.document_id).toBe(docId); 364 + expect(blob.file_name).toBe('test.txt'); 365 + expect(blob.mime_type).toBe('text/plain'); 366 + expect(blob.size).toBe(data.length); 367 + expect((blob.data as Buffer).toString()).toBe('file-content'); 368 + }); 369 + 370 + it('lists blobs for a document', () => { 371 + const docId = randomUUID(); 372 + stmts.insert.run(docId, 'doc', null); 373 + 374 + stmts.insertBlob.run(randomUUID(), docId, 'a.txt', 'text/plain', 5, Buffer.from('aaaaa')); 375 + stmts.insertBlob.run(randomUUID(), docId, 'b.txt', 'text/plain', 5, Buffer.from('bbbbb')); 376 + 377 + const blobs = stmts.listBlobs.all(docId) as Array<Record<string, unknown>>; 378 + expect(blobs.length).toBe(2); 379 + }); 380 + 381 + it('deletes a single blob', () => { 382 + const blobId = randomUUID(); 383 + const docId = randomUUID(); 384 + stmts.insert.run(docId, 'doc', null); 385 + stmts.insertBlob.run(blobId, docId, 'x.txt', 'text/plain', 1, Buffer.from('x')); 386 + 387 + stmts.deleteBlob.run(blobId); 388 + expect(stmts.getBlob.get(blobId)).toBeUndefined(); 389 + }); 390 + 391 + it('deletes all blobs for a document', () => { 392 + const docId = randomUUID(); 393 + stmts.insert.run(docId, 'doc', null); 394 + stmts.insertBlob.run(randomUUID(), docId, 'a.txt', 'text/plain', 1, Buffer.from('a')); 395 + stmts.insertBlob.run(randomUUID(), docId, 'b.txt', 'text/plain', 1, Buffer.from('b')); 396 + 397 + stmts.deleteBlobsForDoc.run(docId); 398 + const blobs = stmts.listBlobs.all(docId) as Array<Record<string, unknown>>; 399 + expect(blobs.length).toBe(0); 400 + }); 401 + }); 402 + 403 + // --- Cascade deletion --- 404 + 405 + describe('Cascade deletion', () => { 406 + it('deleting a document cascades to versions and blobs', () => { 407 + const docId = randomUUID(); 408 + stmts.insertWithOwner.run(docId, 'doc', 'test', 'owner@tailnet'); 409 + 410 + // Add versions 411 + const v1 = randomUUID(); 412 + const v2 = randomUUID(); 413 + stmts.insertVersion.run(v1, docId, Buffer.from('v1'), null); 414 + stmts.insertVersion.run(v2, docId, Buffer.from('v2'), null); 415 + 416 + // Add blobs 417 + const b1 = randomUUID(); 418 + const b2 = randomUUID(); 419 + stmts.insertBlob.run(b1, docId, 'a.txt', 'text/plain', 1, Buffer.from('a')); 420 + stmts.insertBlob.run(b2, docId, 'b.txt', 'text/plain', 1, Buffer.from('b')); 421 + 422 + // Cascade delete in a transaction (mirroring the fix in documents.ts) 423 + db.transaction(() => { 424 + stmts.deleteVersionsForDoc.run(docId); 425 + stmts.deleteBlobsForDoc.run(docId); 426 + stmts.deleteDoc.run(docId); 427 + })(); 428 + 429 + // Document gone 430 + expect(stmts.getOne.get(docId)).toBeUndefined(); 431 + // Versions gone 432 + expect((stmts.countVersions.get(docId) as { count: number }).count).toBe(0); 433 + // Blobs gone 434 + expect((stmts.listBlobs.all(docId) as unknown[]).length).toBe(0); 435 + }); 436 + 437 + it('cascade delete does not affect other documents', () => { 438 + const docA = randomUUID(); 439 + const docB = randomUUID(); 440 + stmts.insert.run(docA, 'doc', null); 441 + stmts.insert.run(docB, 'doc', null); 442 + 443 + stmts.insertVersion.run(randomUUID(), docA, Buffer.from('a'), null); 444 + stmts.insertVersion.run(randomUUID(), docB, Buffer.from('b'), null); 445 + stmts.insertBlob.run(randomUUID(), docA, 'a.txt', 'text/plain', 1, Buffer.from('a')); 446 + stmts.insertBlob.run(randomUUID(), docB, 'b.txt', 'text/plain', 1, Buffer.from('b')); 447 + 448 + // Delete only docA 449 + db.transaction(() => { 450 + stmts.deleteVersionsForDoc.run(docA); 451 + stmts.deleteBlobsForDoc.run(docA); 452 + stmts.deleteDoc.run(docA); 453 + })(); 454 + 455 + // docB still has its data 456 + expect(stmts.getOne.get(docB)).toBeDefined(); 457 + expect((stmts.countVersions.get(docB) as { count: number }).count).toBe(1); 458 + expect((stmts.listBlobs.all(docB) as unknown[]).length).toBe(1); 459 + }); 460 + }); 461 + 462 + // --- User operations --- 463 + 464 + describe('User operations', () => { 465 + it('upserts a user', () => { 466 + stmts.upsertUser.run('alice@tailnet', 'Alice', 'https://example.com/pic.jpg'); 467 + const user = stmts.getUser.get('alice@tailnet') as Record<string, unknown>; 468 + expect(user.name).toBe('Alice'); 469 + expect(user.profile_pic).toBe('https://example.com/pic.jpg'); 470 + }); 471 + 472 + it('updates user on conflict', () => { 473 + stmts.upsertUser.run('bob@tailnet', 'Bob', null); 474 + stmts.upsertUser.run('bob@tailnet', 'Robert', 'https://example.com/bob.jpg'); 475 + const user = stmts.getUser.get('bob@tailnet') as Record<string, unknown>; 476 + expect(user.name).toBe('Robert'); 477 + expect(user.profile_pic).toBe('https://example.com/bob.jpg'); 478 + }); 479 + 480 + it('lists all users', () => { 481 + const users = stmts.getAllUsers.all() as Array<Record<string, unknown>>; 482 + expect(users.length).toBeGreaterThanOrEqual(1); 483 + }); 484 + }); 485 + 486 + // --- Key sync --- 487 + 488 + describe('Key sync', () => { 489 + it('stores and retrieves keys', () => { 490 + stmts.upsertUser.run('keyuser@tailnet', 'Key User', null); 491 + stmts.putKeys.run('keyuser@tailnet', JSON.stringify({ doc1: 'key1' })); 492 + 493 + const row = stmts.getKeys.get('keyuser@tailnet') as { keys_json: string }; 494 + expect(JSON.parse(row.keys_json)).toEqual({ doc1: 'key1' }); 495 + }); 496 + 497 + it('upserts keys on conflict', () => { 498 + stmts.upsertUser.run('keyuser2@tailnet', 'Key User 2', null); 499 + stmts.putKeys.run('keyuser2@tailnet', JSON.stringify({ a: '1' })); 500 + stmts.putKeys.run('keyuser2@tailnet', JSON.stringify({ b: '2' })); 501 + 502 + const row = stmts.getKeys.get('keyuser2@tailnet') as { keys_json: string }; 503 + expect(JSON.parse(row.keys_json)).toEqual({ b: '2' }); 504 + }); 505 + 506 + it('returns undefined for non-existent key user', () => { 507 + expect(stmts.getKeys.get('nonexistent@tailnet')).toBeUndefined(); 508 + }); 509 + }); 510 + 511 + // --- Migration idempotency --- 512 + 513 + describe('Migration idempotency', () => { 514 + it('CREATE TABLE IF NOT EXISTS is safe to run multiple times', () => { 515 + // Running the same schema creation should not throw 516 + expect(() => { 517 + db.exec(` 518 + CREATE TABLE IF NOT EXISTS documents ( 519 + id TEXT PRIMARY KEY, 520 + type TEXT NOT NULL CHECK(type IN ('doc','sheet','form','slide','diagram','calendar')), 521 + name_encrypted TEXT, 522 + snapshot BLOB, 523 + share_mode TEXT DEFAULT 'edit', 524 + expires_at TEXT, 525 + deleted_at TEXT, 526 + tags TEXT, 527 + owner TEXT, 528 + created_at TEXT DEFAULT (datetime('now')), 529 + updated_at TEXT DEFAULT (datetime('now')) 530 + ) 531 + `); 532 + }).not.toThrow(); 533 + }); 534 + 535 + it('ALTER TABLE ADD COLUMN on existing column is handled by try-catch pattern', () => { 536 + // The production code uses try-catch around SELECT column to detect if migration is needed 537 + // Attempting to add an existing column would throw, which is expected 538 + expect(() => { 539 + db.exec("ALTER TABLE documents ADD COLUMN owner TEXT"); 540 + }).toThrow(); // Column already exists 541 + }); 542 + });
+934
tests/server/routes.test.ts
··· 1 + /** 2 + * Integration tests for server API routes. 3 + * Spins up an in-memory Express server that mirrors the production setup, 4 + * testing auth enforcement, owner checks, rate limiting, share link access, 5 + * blob operations, and cascade deletion via HTTP. 6 + */ 7 + 8 + import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 9 + import Database from 'better-sqlite3'; 10 + import { randomUUID } from 'crypto'; 11 + import { createServer } from 'http'; 12 + import express from 'express'; 13 + import compression from 'compression'; 14 + import type { Server } from 'http'; 15 + 16 + let baseUrl: string; 17 + let server: Server; 18 + let db: ReturnType<typeof Database>; 19 + 20 + type Req = express.Request; 21 + type Res = express.Response; 22 + 23 + beforeAll(async () => { 24 + db = new Database(':memory:'); 25 + db.pragma('journal_mode = WAL'); 26 + 27 + // Full production schema 28 + db.exec(` 29 + CREATE TABLE documents ( 30 + id TEXT PRIMARY KEY, 31 + type TEXT NOT NULL CHECK(type IN ('doc','sheet','form','slide','diagram','calendar')), 32 + name_encrypted TEXT, 33 + snapshot BLOB, 34 + share_mode TEXT DEFAULT 'edit', 35 + expires_at TEXT, 36 + deleted_at TEXT, 37 + tags TEXT, 38 + owner TEXT, 39 + created_at TEXT DEFAULT (datetime('now')), 40 + updated_at TEXT DEFAULT (datetime('now')) 41 + ) 42 + `); 43 + db.exec(` 44 + CREATE TABLE versions ( 45 + id TEXT PRIMARY KEY, 46 + document_id TEXT NOT NULL, 47 + snapshot BLOB NOT NULL, 48 + created_at TEXT DEFAULT (datetime('now')), 49 + metadata TEXT 50 + ) 51 + `); 52 + db.exec(` 53 + CREATE TABLE users ( 54 + login TEXT PRIMARY KEY, 55 + name TEXT NOT NULL, 56 + profile_pic TEXT, 57 + first_seen TEXT DEFAULT (datetime('now')), 58 + last_seen TEXT DEFAULT (datetime('now')) 59 + ) 60 + `); 61 + db.exec(` 62 + CREATE TABLE user_keys ( 63 + login TEXT PRIMARY KEY REFERENCES users(login), 64 + keys_json TEXT NOT NULL DEFAULT '{}', 65 + updated_at TEXT DEFAULT (datetime('now')) 66 + ) 67 + `); 68 + db.exec(` 69 + CREATE TABLE blobs ( 70 + id TEXT PRIMARY KEY, 71 + document_id TEXT NOT NULL, 72 + file_name TEXT NOT NULL, 73 + mime_type TEXT NOT NULL, 74 + size INTEGER NOT NULL, 75 + data BLOB NOT NULL, 76 + created_at TEXT DEFAULT (datetime('now')) 77 + ) 78 + `); 79 + 80 + const stmts = { 81 + insert: db.prepare('INSERT INTO documents (id, type, name_encrypted) VALUES (?, ?, ?)'), 82 + insertWithOwner: db.prepare('INSERT INTO documents (id, type, name_encrypted, owner) VALUES (?, ?, ?, ?)'), 83 + getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE id = ?'), 84 + getAll: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NULL ORDER BY updated_at DESC'), 85 + getTrash: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC'), 86 + getSnapshot: db.prepare('SELECT snapshot, expires_at FROM documents WHERE id = ?'), 87 + putSnapshot: db.prepare("UPDATE documents SET snapshot = ?, updated_at = datetime('now') WHERE id = ?"), 88 + putName: db.prepare("UPDATE documents SET name_encrypted = ?, updated_at = datetime('now') WHERE id = ?"), 89 + trashDoc: db.prepare("UPDATE documents SET deleted_at = datetime('now') WHERE id = ? AND deleted_at IS NULL"), 90 + restoreDoc: db.prepare("UPDATE documents SET deleted_at = NULL WHERE id = ?"), 91 + deleteDoc: db.prepare('DELETE FROM documents WHERE id = ?'), 92 + insertVersion: db.prepare('INSERT INTO versions (id, document_id, snapshot, metadata) VALUES (?, ?, ?, ?)'), 93 + getVersions: db.prepare('SELECT id, document_id, created_at, metadata FROM versions WHERE document_id = ? ORDER BY rowid DESC LIMIT 50'), 94 + getVersionSnapshot: db.prepare('SELECT snapshot FROM versions WHERE id = ? AND document_id = ?'), 95 + countVersions: db.prepare('SELECT COUNT(*) as count FROM versions WHERE document_id = ?'), 96 + updateShare: db.prepare("UPDATE documents SET share_mode = ?, expires_at = ?, updated_at = datetime('now') WHERE id = ?"), 97 + putTags: db.prepare("UPDATE documents SET tags = ?, updated_at = datetime('now') WHERE id = ?"), 98 + upsertUser: db.prepare(`INSERT INTO users (login, name, profile_pic) VALUES (?, ?, ?) 99 + ON CONFLICT(login) DO UPDATE SET name=excluded.name, profile_pic=excluded.profile_pic, last_seen=datetime('now')`), 100 + getUser: db.prepare('SELECT * FROM users WHERE login = ?'), 101 + getAllUsers: db.prepare('SELECT login, name, profile_pic FROM users ORDER BY last_seen DESC'), 102 + getKeys: db.prepare('SELECT keys_json FROM user_keys WHERE login = ?'), 103 + putKeys: db.prepare(`INSERT INTO user_keys (login, keys_json, updated_at) VALUES (?, ?, datetime('now')) 104 + ON CONFLICT(login) DO UPDATE SET keys_json = excluded.keys_json, updated_at = datetime('now')`), 105 + insertBlob: db.prepare('INSERT INTO blobs (id, document_id, file_name, mime_type, size, data) VALUES (?, ?, ?, ?, ?, ?)'), 106 + getBlob: db.prepare('SELECT id, document_id, file_name, mime_type, size, data, created_at FROM blobs WHERE id = ?'), 107 + listBlobs: db.prepare('SELECT id, document_id, file_name, mime_type, size, created_at FROM blobs WHERE document_id = ? ORDER BY created_at DESC'), 108 + deleteBlob: db.prepare('DELETE FROM blobs WHERE id = ?'), 109 + deleteBlobsForDoc: db.prepare('DELETE FROM blobs WHERE document_id = ?'), 110 + deleteVersionsForDoc: db.prepare('DELETE FROM versions WHERE document_id = ?'), 111 + }; 112 + 113 + const { isValidDocType } = await import('../../server/validation.js'); 114 + 115 + const app = express(); 116 + app.use(compression()); 117 + app.use(express.json({ limit: '1mb' })); 118 + 119 + // Tailscale identity middleware -- reads test header to simulate auth 120 + app.use((req: Req & { tsUser?: { login: string; name: string; profilePic: string | null } | null }, _res, next) => { 121 + const login = req.headers['tailscale-user-login'] as string | undefined; 122 + const name = req.headers['tailscale-user-name'] as string | undefined; 123 + if (login) { 124 + req.tsUser = { login, name: name || login, profilePic: null }; 125 + stmts.upsertUser.run(login, name || login, null); 126 + } else { 127 + req.tsUser = null; 128 + } 129 + next(); 130 + }); 131 + 132 + // --- Document CRUD --- 133 + app.post('/api/documents', (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 134 + const id = randomUUID(); 135 + const { type, name_encrypted } = req.body; 136 + if (!isValidDocType(type)) { 137 + res.status(400).json({ error: 'type must be doc, sheet, form, slide, diagram, or calendar' }); 138 + return; 139 + } 140 + const owner = req.tsUser?.login || null; 141 + if (owner) { 142 + stmts.insertWithOwner.run(id, type, name_encrypted || null, owner); 143 + } else { 144 + stmts.insert.run(id, type, name_encrypted || null); 145 + } 146 + res.json({ id }); 147 + }); 148 + 149 + app.get('/api/documents', (_req: Req, res: Res) => { 150 + res.json(stmts.getAll.all()); 151 + }); 152 + 153 + app.get('/api/documents/:id', (req: Req, res: Res) => { 154 + const doc = stmts.getOne.get(req.params.id) as Record<string, unknown> | undefined; 155 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 156 + res.json(doc); 157 + }); 158 + 159 + app.delete('/api/documents/:id', (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 160 + const doc = stmts.getOne.get(req.params.id) as Record<string, unknown> | undefined; 161 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 162 + if (doc.owner && req.tsUser?.login !== doc.owner) { 163 + res.status(403).json({ error: 'Only the document owner can delete' }); 164 + return; 165 + } 166 + db.transaction(() => { 167 + stmts.deleteVersionsForDoc.run(req.params.id); 168 + stmts.deleteBlobsForDoc.run(req.params.id); 169 + stmts.deleteDoc.run(req.params.id); 170 + })(); 171 + res.json({ ok: true }); 172 + }); 173 + 174 + app.put('/api/documents/:id/trash', (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 175 + const doc = stmts.getOne.get(req.params.id) as Record<string, unknown> | undefined; 176 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 177 + if (doc.owner && req.tsUser?.login !== doc.owner) { 178 + res.status(403).json({ error: 'Only the document owner can trash' }); 179 + return; 180 + } 181 + stmts.trashDoc.run(req.params.id); 182 + res.json({ ok: true }); 183 + }); 184 + 185 + app.put('/api/documents/:id/restore', (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 186 + const doc = stmts.getOne.get(req.params.id) as Record<string, unknown> | undefined; 187 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 188 + if (doc.owner && req.tsUser?.login !== doc.owner) { 189 + res.status(403).json({ error: 'Only the document owner can restore' }); 190 + return; 191 + } 192 + stmts.restoreDoc.run(req.params.id); 193 + res.json({ ok: true }); 194 + }); 195 + 196 + app.put('/api/documents/:id/name', (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 197 + const { name_encrypted } = req.body; 198 + if (name_encrypted != null && typeof name_encrypted !== 'string') { 199 + return res.status(400).json({ error: 'name_encrypted must be a string or null' }); 200 + } 201 + if (typeof name_encrypted === 'string' && name_encrypted.length > 10000) { 202 + return res.status(400).json({ error: 'name_encrypted too long' }); 203 + } 204 + const doc = stmts.getOne.get(req.params.id) as Record<string, unknown> | undefined; 205 + if (doc?.owner && req.tsUser?.login !== doc.owner) { 206 + return res.status(403).json({ error: 'Only the document owner can rename' }); 207 + } 208 + stmts.putName.run(name_encrypted, req.params.id); 209 + res.json({ ok: true }); 210 + }); 211 + 212 + app.put('/api/documents/:id/tags', (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 213 + const { tags } = req.body; 214 + if (tags != null && typeof tags !== 'string') { 215 + return res.status(400).json({ error: 'tags must be a string or null' }); 216 + } 217 + const doc = stmts.getOne.get(req.params.id) as Record<string, unknown> | undefined; 218 + if (doc?.owner && req.tsUser?.login !== doc.owner) { 219 + return res.status(403).json({ error: 'Only the document owner can update tags' }); 220 + } 221 + stmts.putTags.run(tags ?? null, req.params.id); 222 + res.json({ ok: true }); 223 + }); 224 + 225 + // Snapshot handler 226 + const snapshotMw = express.raw({ limit: '50mb', type: '*/*' }); 227 + const snapshotFn = (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 228 + if (!req.body || !Buffer.isBuffer(req.body) || req.body.length === 0) { 229 + res.status(400).json({ error: 'Empty or missing snapshot body' }); 230 + return; 231 + } 232 + const existing = stmts.getOne.get(req.params.id) as Record<string, unknown> | undefined; 233 + if (existing?.owner && req.tsUser?.login !== existing.owner && existing.share_mode !== 'edit') { 234 + res.status(403).json({ error: 'Only the document owner can save snapshots' }); 235 + return; 236 + } 237 + const result = stmts.putSnapshot.run(req.body, req.params.id); 238 + if (result.changes === 0) { 239 + const docType = (req.headers['x-document-type'] as string) || 'doc'; 240 + const validType = isValidDocType(docType) ? docType : 'doc'; 241 + db.transaction(() => { 242 + db.prepare("INSERT OR IGNORE INTO documents (id, type, name_encrypted) VALUES (?, ?, NULL)").run(req.params.id, validType); 243 + stmts.putSnapshot.run(req.body, req.params.id); 244 + })(); 245 + } 246 + res.json({ ok: true }); 247 + }; 248 + app.put('/api/documents/:id/snapshot', snapshotMw, snapshotFn); 249 + app.post('/api/documents/:id/snapshot', snapshotMw, snapshotFn); 250 + 251 + app.get('/api/documents/:id/snapshot', (req: Req, res: Res) => { 252 + const row = stmts.getSnapshot.get(req.params.id) as { snapshot: Buffer | null; expires_at: string | null } | undefined; 253 + if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; } 254 + res.type('application/octet-stream').send(row.snapshot); 255 + }); 256 + 257 + // Sharing 258 + app.put('/api/documents/:id/share', (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 259 + const doc = stmts.getOne.get(req.params.id) as Record<string, unknown> | undefined; 260 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 261 + if (doc.owner && req.tsUser?.login !== doc.owner) { 262 + res.status(403).json({ error: 'Only the document owner can change sharing settings' }); 263 + return; 264 + } 265 + const { share_mode } = req.body; 266 + stmts.updateShare.run(share_mode || doc.share_mode, null, req.params.id); 267 + res.json({ ok: true }); 268 + }); 269 + 270 + // Versions 271 + app.post('/api/documents/:id/versions', express.raw({ limit: '50mb', type: '*/*' }), (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 272 + const docId = req.params.id; 273 + const doc = stmts.getOne.get(docId) as Record<string, unknown> | undefined; 274 + if (doc?.owner && req.tsUser?.login !== doc.owner && doc.share_mode !== 'edit') { 275 + res.status(403).json({ error: 'Only the document owner can create versions' }); 276 + return; 277 + } 278 + const id = randomUUID(); 279 + stmts.insertVersion.run(id, docId, req.body, null); 280 + res.json({ id }); 281 + }); 282 + 283 + app.get('/api/documents/:id/versions', (req: Req, res: Res) => { 284 + res.json(stmts.getVersions.all(req.params.id)); 285 + }); 286 + 287 + // Blobs 288 + app.post('/api/blobs', express.raw({ limit: '10mb', type: '*/*' }), (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 289 + const docId = req.headers['x-document-id'] as string; 290 + if (!docId) return res.status(400).json({ error: 'x-document-id header required' }); 291 + const doc = stmts.getOne.get(docId) as Record<string, unknown> | undefined; 292 + if (doc?.owner && req.tsUser?.login !== doc.owner && doc.share_mode !== 'edit') { 293 + return res.status(403).json({ error: 'Only the document owner can upload blobs' }); 294 + } 295 + const data = req.body; 296 + if (!data || !data.length) return res.status(400).json({ error: 'No data' }); 297 + const BLOB_MAX_SIZE = 10 * 1024 * 1024; 298 + if (data.length > BLOB_MAX_SIZE) return res.status(413).json({ error: 'Blob too large (max 10MB)' }); 299 + const id = randomUUID(); 300 + stmts.insertBlob.run(id, docId, 'file', 'application/octet-stream', data.length, data); 301 + res.status(201).json({ id, size: data.length }); 302 + }); 303 + 304 + app.get('/api/blobs/:id', (req: Req, res: Res) => { 305 + const row = stmts.getBlob.get(req.params.id) as Record<string, unknown> | undefined; 306 + if (!row) return res.status(404).json({ error: 'Not found' }); 307 + res.type(row.mime_type as string).send(row.data); 308 + }); 309 + 310 + app.delete('/api/blobs/:id', (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 311 + const blob = stmts.getBlob.get(req.params.id) as Record<string, unknown> | undefined; 312 + if (!blob) { res.status(404).json({ error: 'Not found' }); return; } 313 + const doc = stmts.getOne.get(blob.document_id as string) as Record<string, unknown> | undefined; 314 + if (doc?.owner && req.tsUser?.login !== doc.owner) { 315 + res.status(403).json({ error: 'Only the document owner can delete blobs' }); 316 + return; 317 + } 318 + stmts.deleteBlob.run(req.params.id); 319 + res.json({ ok: true }); 320 + }); 321 + 322 + // Health 323 + app.get('/health', (_req: Req, res: Res) => res.json({ status: 'ok' })); 324 + 325 + server = createServer(app); 326 + await new Promise<void>((resolve) => { 327 + server.listen(0, () => { 328 + const addr = server.address(); 329 + baseUrl = `http://localhost:${typeof addr === 'object' && addr ? addr.port : 0}`; 330 + resolve(); 331 + }); 332 + }); 333 + }); 334 + 335 + afterAll(() => { 336 + server?.close(); 337 + db?.close(); 338 + }); 339 + 340 + // --- Helpers --- 341 + 342 + function authHeaders(login: string): Record<string, string> { 343 + return { 'tailscale-user-login': login, 'tailscale-user-name': login }; 344 + } 345 + 346 + async function createDoc(type: string, owner?: string): Promise<string> { 347 + const headers: Record<string, string> = { 'Content-Type': 'application/json' }; 348 + if (owner) Object.assign(headers, authHeaders(owner)); 349 + const res = await fetch(`${baseUrl}/api/documents`, { 350 + method: 'POST', 351 + headers, 352 + body: JSON.stringify({ type }), 353 + }); 354 + const data = await res.json() as { id: string }; 355 + return data.id; 356 + } 357 + 358 + // --- Tests --- 359 + 360 + describe('health check', () => { 361 + it('returns ok', async () => { 362 + const res = await fetch(`${baseUrl}/health`); 363 + const data = await res.json() as Record<string, unknown>; 364 + expect(data.status).toBe('ok'); 365 + }); 366 + }); 367 + 368 + describe('document creation', () => { 369 + it('creates a document with valid type', async () => { 370 + const res = await fetch(`${baseUrl}/api/documents`, { 371 + method: 'POST', 372 + headers: { 'Content-Type': 'application/json' }, 373 + body: JSON.stringify({ type: 'doc' }), 374 + }); 375 + expect(res.status).toBe(200); 376 + const data = await res.json() as Record<string, unknown>; 377 + expect(data.id).toBeDefined(); 378 + }); 379 + 380 + it('creates all valid document types including calendar', async () => { 381 + for (const type of ['doc', 'sheet', 'form', 'slide', 'diagram', 'calendar']) { 382 + const res = await fetch(`${baseUrl}/api/documents`, { 383 + method: 'POST', 384 + headers: { 'Content-Type': 'application/json' }, 385 + body: JSON.stringify({ type }), 386 + }); 387 + expect(res.status).toBe(200); 388 + } 389 + }); 390 + 391 + it('rejects invalid document type', async () => { 392 + const res = await fetch(`${baseUrl}/api/documents`, { 393 + method: 'POST', 394 + headers: { 'Content-Type': 'application/json' }, 395 + body: JSON.stringify({ type: 'invalid' }), 396 + }); 397 + expect(res.status).toBe(400); 398 + }); 399 + 400 + it('sets owner from Tailscale auth', async () => { 401 + const id = await createDoc('doc', 'scott@tailnet'); 402 + const res = await fetch(`${baseUrl}/api/documents/${id}`); 403 + const doc = await res.json() as Record<string, unknown>; 404 + expect(doc.owner).toBe('scott@tailnet'); 405 + }); 406 + 407 + it('leaves owner null when no auth', async () => { 408 + const id = await createDoc('doc'); 409 + const res = await fetch(`${baseUrl}/api/documents/${id}`); 410 + const doc = await res.json() as Record<string, unknown>; 411 + expect(doc.owner).toBeNull(); 412 + }); 413 + }); 414 + 415 + // --- Owner authorization checks --- 416 + 417 + describe('owner authorization: trash', () => { 418 + it('allows owner to trash their document', async () => { 419 + const id = await createDoc('doc', 'alice@tailnet'); 420 + const res = await fetch(`${baseUrl}/api/documents/${id}/trash`, { 421 + method: 'PUT', 422 + headers: authHeaders('alice@tailnet'), 423 + }); 424 + expect(res.status).toBe(200); 425 + }); 426 + 427 + it('blocks non-owner from trashing', async () => { 428 + const id = await createDoc('doc', 'alice@tailnet'); 429 + const res = await fetch(`${baseUrl}/api/documents/${id}/trash`, { 430 + method: 'PUT', 431 + headers: authHeaders('eve@tailnet'), 432 + }); 433 + expect(res.status).toBe(403); 434 + }); 435 + 436 + it('allows trashing documents with no owner', async () => { 437 + const id = await createDoc('doc'); 438 + const res = await fetch(`${baseUrl}/api/documents/${id}/trash`, { 439 + method: 'PUT', 440 + }); 441 + expect(res.status).toBe(200); 442 + }); 443 + }); 444 + 445 + describe('owner authorization: restore', () => { 446 + it('allows owner to restore their document', async () => { 447 + const id = await createDoc('doc', 'alice@tailnet'); 448 + await fetch(`${baseUrl}/api/documents/${id}/trash`, { 449 + method: 'PUT', 450 + headers: authHeaders('alice@tailnet'), 451 + }); 452 + const res = await fetch(`${baseUrl}/api/documents/${id}/restore`, { 453 + method: 'PUT', 454 + headers: authHeaders('alice@tailnet'), 455 + }); 456 + expect(res.status).toBe(200); 457 + }); 458 + 459 + it('blocks non-owner from restoring', async () => { 460 + const id = await createDoc('doc', 'alice@tailnet'); 461 + await fetch(`${baseUrl}/api/documents/${id}/trash`, { 462 + method: 'PUT', 463 + headers: authHeaders('alice@tailnet'), 464 + }); 465 + const res = await fetch(`${baseUrl}/api/documents/${id}/restore`, { 466 + method: 'PUT', 467 + headers: authHeaders('eve@tailnet'), 468 + }); 469 + expect(res.status).toBe(403); 470 + }); 471 + }); 472 + 473 + describe('owner authorization: rename', () => { 474 + it('allows owner to rename', async () => { 475 + const id = await createDoc('doc', 'alice@tailnet'); 476 + const res = await fetch(`${baseUrl}/api/documents/${id}/name`, { 477 + method: 'PUT', 478 + headers: { 'Content-Type': 'application/json', ...authHeaders('alice@tailnet') }, 479 + body: JSON.stringify({ name_encrypted: 'new-name' }), 480 + }); 481 + expect(res.status).toBe(200); 482 + }); 483 + 484 + it('blocks non-owner from renaming', async () => { 485 + const id = await createDoc('doc', 'alice@tailnet'); 486 + const res = await fetch(`${baseUrl}/api/documents/${id}/name`, { 487 + method: 'PUT', 488 + headers: { 'Content-Type': 'application/json', ...authHeaders('eve@tailnet') }, 489 + body: JSON.stringify({ name_encrypted: 'evil-name' }), 490 + }); 491 + expect(res.status).toBe(403); 492 + }); 493 + 494 + it('allows renaming documents with no owner', async () => { 495 + const id = await createDoc('doc'); 496 + const res = await fetch(`${baseUrl}/api/documents/${id}/name`, { 497 + method: 'PUT', 498 + headers: { 'Content-Type': 'application/json' }, 499 + body: JSON.stringify({ name_encrypted: 'anon-name' }), 500 + }); 501 + expect(res.status).toBe(200); 502 + }); 503 + }); 504 + 505 + describe('owner authorization: tags', () => { 506 + it('allows owner to update tags', async () => { 507 + const id = await createDoc('doc', 'alice@tailnet'); 508 + const res = await fetch(`${baseUrl}/api/documents/${id}/tags`, { 509 + method: 'PUT', 510 + headers: { 'Content-Type': 'application/json', ...authHeaders('alice@tailnet') }, 511 + body: JSON.stringify({ tags: 'important' }), 512 + }); 513 + expect(res.status).toBe(200); 514 + }); 515 + 516 + it('blocks non-owner from updating tags', async () => { 517 + const id = await createDoc('doc', 'alice@tailnet'); 518 + const res = await fetch(`${baseUrl}/api/documents/${id}/tags`, { 519 + method: 'PUT', 520 + headers: { 'Content-Type': 'application/json', ...authHeaders('eve@tailnet') }, 521 + body: JSON.stringify({ tags: 'hacked' }), 522 + }); 523 + expect(res.status).toBe(403); 524 + }); 525 + }); 526 + 527 + describe('owner authorization: delete', () => { 528 + it('allows owner to delete', async () => { 529 + const id = await createDoc('doc', 'alice@tailnet'); 530 + const res = await fetch(`${baseUrl}/api/documents/${id}`, { 531 + method: 'DELETE', 532 + headers: authHeaders('alice@tailnet'), 533 + }); 534 + expect(res.status).toBe(200); 535 + }); 536 + 537 + it('blocks non-owner from deleting', async () => { 538 + const id = await createDoc('doc', 'alice@tailnet'); 539 + const res = await fetch(`${baseUrl}/api/documents/${id}`, { 540 + method: 'DELETE', 541 + headers: authHeaders('eve@tailnet'), 542 + }); 543 + expect(res.status).toBe(403); 544 + }); 545 + 546 + it('returns 404 for non-existent document', async () => { 547 + const res = await fetch(`${baseUrl}/api/documents/nonexistent`, { 548 + method: 'DELETE', 549 + headers: authHeaders('alice@tailnet'), 550 + }); 551 + expect(res.status).toBe(404); 552 + }); 553 + }); 554 + 555 + describe('owner authorization: share settings', () => { 556 + it('allows owner to change sharing', async () => { 557 + const id = await createDoc('doc', 'alice@tailnet'); 558 + const res = await fetch(`${baseUrl}/api/documents/${id}/share`, { 559 + method: 'PUT', 560 + headers: { 'Content-Type': 'application/json', ...authHeaders('alice@tailnet') }, 561 + body: JSON.stringify({ share_mode: 'view' }), 562 + }); 563 + expect(res.status).toBe(200); 564 + }); 565 + 566 + it('blocks non-owner from changing sharing', async () => { 567 + const id = await createDoc('doc', 'alice@tailnet'); 568 + const res = await fetch(`${baseUrl}/api/documents/${id}/share`, { 569 + method: 'PUT', 570 + headers: { 'Content-Type': 'application/json', ...authHeaders('eve@tailnet') }, 571 + body: JSON.stringify({ share_mode: 'edit' }), 572 + }); 573 + expect(res.status).toBe(403); 574 + }); 575 + }); 576 + 577 + // --- Snapshot operations --- 578 + 579 + describe('snapshot save with auth', () => { 580 + it('allows owner to save snapshot', async () => { 581 + const id = await createDoc('doc', 'alice@tailnet'); 582 + const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`, { 583 + method: 'PUT', 584 + headers: { 'Content-Type': 'application/octet-stream', ...authHeaders('alice@tailnet') }, 585 + body: new Uint8Array([1, 2, 3]), 586 + }); 587 + expect(res.status).toBe(200); 588 + }); 589 + 590 + it('blocks non-owner from saving snapshot on view-only doc', async () => { 591 + const id = await createDoc('doc', 'alice@tailnet'); 592 + // Change to view-only 593 + await fetch(`${baseUrl}/api/documents/${id}/share`, { 594 + method: 'PUT', 595 + headers: { 'Content-Type': 'application/json', ...authHeaders('alice@tailnet') }, 596 + body: JSON.stringify({ share_mode: 'view' }), 597 + }); 598 + const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`, { 599 + method: 'PUT', 600 + headers: { 'Content-Type': 'application/octet-stream', ...authHeaders('eve@tailnet') }, 601 + body: new Uint8Array([1, 2, 3]), 602 + }); 603 + expect(res.status).toBe(403); 604 + }); 605 + 606 + it('allows non-owner to save snapshot on edit-shared doc', async () => { 607 + const id = await createDoc('doc', 'alice@tailnet'); 608 + // share_mode defaults to 'edit' 609 + const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`, { 610 + method: 'PUT', 611 + headers: { 'Content-Type': 'application/octet-stream', ...authHeaders('bob@tailnet') }, 612 + body: new Uint8Array([1, 2, 3]), 613 + }); 614 + expect(res.status).toBe(200); 615 + }); 616 + 617 + it('rejects empty snapshot body', async () => { 618 + const id = await createDoc('doc'); 619 + const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`, { 620 + method: 'PUT', 621 + headers: { 'Content-Type': 'application/octet-stream' }, 622 + body: new Uint8Array(0), 623 + }); 624 + expect(res.status).toBe(400); 625 + }); 626 + }); 627 + 628 + describe('snapshot auto-create with document type', () => { 629 + it('auto-creates document with type from header', async () => { 630 + const id = randomUUID(); 631 + await fetch(`${baseUrl}/api/documents/${id}/snapshot`, { 632 + method: 'PUT', 633 + headers: { 634 + 'Content-Type': 'application/octet-stream', 635 + 'x-document-type': 'sheet', 636 + }, 637 + body: new Uint8Array([1, 2, 3]), 638 + }); 639 + 640 + const res = await fetch(`${baseUrl}/api/documents/${id}`); 641 + const doc = await res.json() as Record<string, unknown>; 642 + expect(doc.type).toBe('sheet'); 643 + }); 644 + 645 + it('falls back to doc type for invalid x-document-type', async () => { 646 + const id = randomUUID(); 647 + await fetch(`${baseUrl}/api/documents/${id}/snapshot`, { 648 + method: 'PUT', 649 + headers: { 650 + 'Content-Type': 'application/octet-stream', 651 + 'x-document-type': 'invalid-type', 652 + }, 653 + body: new Uint8Array([4, 5, 6]), 654 + }); 655 + 656 + const res = await fetch(`${baseUrl}/api/documents/${id}`); 657 + const doc = await res.json() as Record<string, unknown>; 658 + expect(doc.type).toBe('doc'); 659 + }); 660 + 661 + it('defaults to doc when no x-document-type header', async () => { 662 + const id = randomUUID(); 663 + await fetch(`${baseUrl}/api/documents/${id}/snapshot`, { 664 + method: 'PUT', 665 + headers: { 'Content-Type': 'application/octet-stream' }, 666 + body: new Uint8Array([7, 8, 9]), 667 + }); 668 + 669 + const res = await fetch(`${baseUrl}/api/documents/${id}`); 670 + const doc = await res.json() as Record<string, unknown>; 671 + expect(doc.type).toBe('doc'); 672 + }); 673 + }); 674 + 675 + // --- Share link access (GET snapshot should work without auth) --- 676 + 677 + describe('share link access', () => { 678 + it('allows reading snapshot without auth (share link)', async () => { 679 + const id = await createDoc('doc', 'alice@tailnet'); 680 + await fetch(`${baseUrl}/api/documents/${id}/snapshot`, { 681 + method: 'PUT', 682 + headers: { 'Content-Type': 'application/octet-stream', ...authHeaders('alice@tailnet') }, 683 + body: new Uint8Array([42, 43, 44]), 684 + }); 685 + 686 + // No auth headers -- simulates share link access 687 + const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`); 688 + expect(res.status).toBe(200); 689 + const data = new Uint8Array(await res.arrayBuffer()); 690 + expect(Array.from(data)).toEqual([42, 43, 44]); 691 + }); 692 + 693 + it('allows listing versions without auth', async () => { 694 + const id = await createDoc('doc', 'alice@tailnet'); 695 + const res = await fetch(`${baseUrl}/api/documents/${id}/versions`); 696 + expect(res.status).toBe(200); 697 + }); 698 + 699 + it('allows reading document metadata without auth', async () => { 700 + const id = await createDoc('doc', 'alice@tailnet'); 701 + const res = await fetch(`${baseUrl}/api/documents/${id}`); 702 + expect(res.status).toBe(200); 703 + }); 704 + }); 705 + 706 + // --- Version authorization --- 707 + 708 + describe('version creation auth', () => { 709 + it('allows owner to create version', async () => { 710 + const id = await createDoc('doc', 'alice@tailnet'); 711 + const res = await fetch(`${baseUrl}/api/documents/${id}/versions`, { 712 + method: 'POST', 713 + headers: { 'Content-Type': 'application/octet-stream', ...authHeaders('alice@tailnet') }, 714 + body: new Uint8Array([1, 2, 3]), 715 + }); 716 + expect(res.status).toBe(200); 717 + }); 718 + 719 + it('blocks non-owner on view-only doc', async () => { 720 + const id = await createDoc('doc', 'alice@tailnet'); 721 + await fetch(`${baseUrl}/api/documents/${id}/share`, { 722 + method: 'PUT', 723 + headers: { 'Content-Type': 'application/json', ...authHeaders('alice@tailnet') }, 724 + body: JSON.stringify({ share_mode: 'view' }), 725 + }); 726 + const res = await fetch(`${baseUrl}/api/documents/${id}/versions`, { 727 + method: 'POST', 728 + headers: { 'Content-Type': 'application/octet-stream', ...authHeaders('eve@tailnet') }, 729 + body: new Uint8Array([1, 2, 3]), 730 + }); 731 + expect(res.status).toBe(403); 732 + }); 733 + 734 + it('allows non-owner on edit-shared doc', async () => { 735 + const id = await createDoc('doc', 'alice@tailnet'); 736 + const res = await fetch(`${baseUrl}/api/documents/${id}/versions`, { 737 + method: 'POST', 738 + headers: { 'Content-Type': 'application/octet-stream', ...authHeaders('bob@tailnet') }, 739 + body: new Uint8Array([1, 2, 3]), 740 + }); 741 + expect(res.status).toBe(200); 742 + }); 743 + }); 744 + 745 + // --- Blob authorization --- 746 + 747 + describe('blob authorization', () => { 748 + it('allows owner to upload blob', async () => { 749 + const id = await createDoc('doc', 'alice@tailnet'); 750 + const res = await fetch(`${baseUrl}/api/blobs`, { 751 + method: 'POST', 752 + headers: { 753 + 'Content-Type': 'application/octet-stream', 754 + 'x-document-id': id, 755 + ...authHeaders('alice@tailnet'), 756 + }, 757 + body: new Uint8Array([1, 2, 3]), 758 + }); 759 + expect(res.status).toBe(201); 760 + }); 761 + 762 + it('blocks non-owner blob upload on view-only doc', async () => { 763 + const id = await createDoc('doc', 'alice@tailnet'); 764 + await fetch(`${baseUrl}/api/documents/${id}/share`, { 765 + method: 'PUT', 766 + headers: { 'Content-Type': 'application/json', ...authHeaders('alice@tailnet') }, 767 + body: JSON.stringify({ share_mode: 'view' }), 768 + }); 769 + const res = await fetch(`${baseUrl}/api/blobs`, { 770 + method: 'POST', 771 + headers: { 772 + 'Content-Type': 'application/octet-stream', 773 + 'x-document-id': id, 774 + ...authHeaders('eve@tailnet'), 775 + }, 776 + body: new Uint8Array([1, 2, 3]), 777 + }); 778 + expect(res.status).toBe(403); 779 + }); 780 + 781 + it('allows non-owner blob upload on edit-shared doc', async () => { 782 + const id = await createDoc('doc', 'alice@tailnet'); 783 + const res = await fetch(`${baseUrl}/api/blobs`, { 784 + method: 'POST', 785 + headers: { 786 + 'Content-Type': 'application/octet-stream', 787 + 'x-document-id': id, 788 + ...authHeaders('bob@tailnet'), 789 + }, 790 + body: new Uint8Array([1, 2, 3]), 791 + }); 792 + expect(res.status).toBe(201); 793 + }); 794 + 795 + it('blocks non-owner from deleting blob', async () => { 796 + const id = await createDoc('doc', 'alice@tailnet'); 797 + const blobRes = await fetch(`${baseUrl}/api/blobs`, { 798 + method: 'POST', 799 + headers: { 800 + 'Content-Type': 'application/octet-stream', 801 + 'x-document-id': id, 802 + ...authHeaders('alice@tailnet'), 803 + }, 804 + body: new Uint8Array([1, 2, 3]), 805 + }); 806 + const { id: blobId } = await blobRes.json() as { id: string }; 807 + 808 + const res = await fetch(`${baseUrl}/api/blobs/${blobId}`, { 809 + method: 'DELETE', 810 + headers: authHeaders('eve@tailnet'), 811 + }); 812 + expect(res.status).toBe(403); 813 + }); 814 + 815 + it('allows owner to delete blob', async () => { 816 + const id = await createDoc('doc', 'alice@tailnet'); 817 + const blobRes = await fetch(`${baseUrl}/api/blobs`, { 818 + method: 'POST', 819 + headers: { 820 + 'Content-Type': 'application/octet-stream', 821 + 'x-document-id': id, 822 + ...authHeaders('alice@tailnet'), 823 + }, 824 + body: new Uint8Array([1, 2, 3]), 825 + }); 826 + const { id: blobId } = await blobRes.json() as { id: string }; 827 + 828 + const res = await fetch(`${baseUrl}/api/blobs/${blobId}`, { 829 + method: 'DELETE', 830 + headers: authHeaders('alice@tailnet'), 831 + }); 832 + expect(res.status).toBe(200); 833 + }); 834 + 835 + it('returns 404 when deleting non-existent blob', async () => { 836 + const res = await fetch(`${baseUrl}/api/blobs/nonexistent`, { 837 + method: 'DELETE', 838 + headers: authHeaders('alice@tailnet'), 839 + }); 840 + expect(res.status).toBe(404); 841 + }); 842 + 843 + it('rejects blob upload without x-document-id', async () => { 844 + const res = await fetch(`${baseUrl}/api/blobs`, { 845 + method: 'POST', 846 + headers: { 'Content-Type': 'application/octet-stream' }, 847 + body: new Uint8Array([1, 2, 3]), 848 + }); 849 + expect(res.status).toBe(400); 850 + }); 851 + }); 852 + 853 + // --- Cascade deletion via HTTP --- 854 + 855 + describe('cascade deletion via API', () => { 856 + it('deleting a document removes its versions and blobs', async () => { 857 + const id = await createDoc('doc', 'alice@tailnet'); 858 + 859 + // Create a version 860 + const vRes = await fetch(`${baseUrl}/api/documents/${id}/versions`, { 861 + method: 'POST', 862 + headers: { 'Content-Type': 'application/octet-stream', ...authHeaders('alice@tailnet') }, 863 + body: new Uint8Array([1, 2]), 864 + }); 865 + expect(vRes.status).toBe(200); 866 + 867 + // Create a blob 868 + const bRes = await fetch(`${baseUrl}/api/blobs`, { 869 + method: 'POST', 870 + headers: { 871 + 'Content-Type': 'application/octet-stream', 872 + 'x-document-id': id, 873 + ...authHeaders('alice@tailnet'), 874 + }, 875 + body: new Uint8Array([3, 4]), 876 + }); 877 + expect(bRes.status).toBe(201); 878 + const { id: blobId } = await bRes.json() as { id: string }; 879 + 880 + // Delete the document 881 + const delRes = await fetch(`${baseUrl}/api/documents/${id}`, { 882 + method: 'DELETE', 883 + headers: authHeaders('alice@tailnet'), 884 + }); 885 + expect(delRes.status).toBe(200); 886 + 887 + // Document gone 888 + const docRes = await fetch(`${baseUrl}/api/documents/${id}`); 889 + expect(docRes.status).toBe(404); 890 + 891 + // Versions gone 892 + const versRes = await fetch(`${baseUrl}/api/documents/${id}/versions`); 893 + const versions = await versRes.json() as unknown[]; 894 + expect(versions.length).toBe(0); 895 + 896 + // Blob gone 897 + const blobRes2 = await fetch(`${baseUrl}/api/blobs/${blobId}`); 898 + expect(blobRes2.status).toBe(404); 899 + }); 900 + }); 901 + 902 + // --- Validation on endpoints --- 903 + 904 + describe('input validation', () => { 905 + it('rejects name_encrypted longer than 10000 chars', async () => { 906 + const id = await createDoc('doc'); 907 + const res = await fetch(`${baseUrl}/api/documents/${id}/name`, { 908 + method: 'PUT', 909 + headers: { 'Content-Type': 'application/json' }, 910 + body: JSON.stringify({ name_encrypted: 'x'.repeat(10001) }), 911 + }); 912 + expect(res.status).toBe(400); 913 + }); 914 + 915 + it('rejects non-string name_encrypted', async () => { 916 + const id = await createDoc('doc'); 917 + const res = await fetch(`${baseUrl}/api/documents/${id}/name`, { 918 + method: 'PUT', 919 + headers: { 'Content-Type': 'application/json' }, 920 + body: JSON.stringify({ name_encrypted: 123 }), 921 + }); 922 + expect(res.status).toBe(400); 923 + }); 924 + 925 + it('rejects non-string tags', async () => { 926 + const id = await createDoc('doc'); 927 + const res = await fetch(`${baseUrl}/api/documents/${id}/tags`, { 928 + method: 'PUT', 929 + headers: { 'Content-Type': 'application/json' }, 930 + body: JSON.stringify({ tags: 123 }), 931 + }); 932 + expect(res.status).toBe(400); 933 + }); 934 + });
+411
tests/server/validation.test.ts
··· 1 + /** 2 + * Comprehensive server validation tests. 3 + * Tests all validation functions in server/validation.ts including edge cases, 4 + * SQL injection attempts, and boundary conditions. 5 + */ 6 + 7 + import { describe, it, expect, beforeEach } from 'vitest'; 8 + import { 9 + isValidDocId, 10 + RateLimiter, 11 + isValidShareMode, 12 + isValidDocType, 13 + isValidMimeType, 14 + filterMetadata, 15 + sanitizeAiRequest, 16 + } from '../../server/validation.js'; 17 + 18 + // --- isValidDocId --- 19 + 20 + describe('isValidDocId', () => { 21 + it('accepts standard UUID format', () => { 22 + expect(isValidDocId('a1b2c3d4-e5f6-7890-1234-5678abcdef01')).toBe(true); 23 + }); 24 + 25 + it('accepts UUID without dashes', () => { 26 + expect(isValidDocId('a1b2c3d4e5f6789012345678abcdef01')).toBe(true); 27 + }); 28 + 29 + it('accepts alphanumeric strings', () => { 30 + expect(isValidDocId('myDocument123')).toBe(true); 31 + expect(isValidDocId('ABC')).toBe(true); 32 + expect(isValidDocId('x')).toBe(true); 33 + }); 34 + 35 + it('accepts underscores and hyphens', () => { 36 + expect(isValidDocId('my_doc_id')).toBe(true); 37 + expect(isValidDocId('my-doc-id')).toBe(true); 38 + expect(isValidDocId('a_b-c_d')).toBe(true); 39 + }); 40 + 41 + it('accepts exactly 100 characters', () => { 42 + expect(isValidDocId('a'.repeat(100))).toBe(true); 43 + }); 44 + 45 + it('rejects empty string', () => { 46 + expect(isValidDocId('')).toBe(false); 47 + }); 48 + 49 + it('rejects strings over 100 characters', () => { 50 + expect(isValidDocId('a'.repeat(101))).toBe(false); 51 + }); 52 + 53 + it('rejects spaces', () => { 54 + expect(isValidDocId('my doc')).toBe(false); 55 + expect(isValidDocId(' leading')).toBe(false); 56 + expect(isValidDocId('trailing ')).toBe(false); 57 + }); 58 + 59 + it('rejects dots and slashes', () => { 60 + expect(isValidDocId('doc.txt')).toBe(false); 61 + expect(isValidDocId('path/to/doc')).toBe(false); 62 + expect(isValidDocId('..\\..\\etc')).toBe(false); 63 + }); 64 + 65 + it('rejects path traversal attempts', () => { 66 + expect(isValidDocId('../etc/passwd')).toBe(false); 67 + expect(isValidDocId('../../etc/shadow')).toBe(false); 68 + expect(isValidDocId('%2e%2e%2f')).toBe(false); 69 + }); 70 + 71 + it('rejects SQL injection attempts', () => { 72 + expect(isValidDocId("'; DROP TABLE documents; --")).toBe(false); 73 + expect(isValidDocId("1' OR '1'='1")).toBe(false); 74 + expect(isValidDocId('1; SELECT * FROM users')).toBe(false); 75 + expect(isValidDocId("doc' UNION SELECT password FROM users--")).toBe(false); 76 + expect(isValidDocId('Robert"); DROP TABLE Students;--')).toBe(false); 77 + }); 78 + 79 + it('rejects XSS attempts', () => { 80 + expect(isValidDocId('<script>alert(1)</script>')).toBe(false); 81 + expect(isValidDocId('"><img src=x onerror=alert(1)>')).toBe(false); 82 + expect(isValidDocId("javascript:alert('xss')")).toBe(false); 83 + }); 84 + 85 + it('rejects special characters', () => { 86 + expect(isValidDocId('doc@home')).toBe(false); 87 + expect(isValidDocId('doc#1')).toBe(false); 88 + expect(isValidDocId('doc$money')).toBe(false); 89 + expect(isValidDocId('doc&more')).toBe(false); 90 + expect(isValidDocId('doc=value')).toBe(false); 91 + expect(isValidDocId('doc+plus')).toBe(false); 92 + }); 93 + 94 + it('rejects non-string types', () => { 95 + expect(isValidDocId(null as unknown as string)).toBe(false); 96 + expect(isValidDocId(undefined as unknown as string)).toBe(false); 97 + expect(isValidDocId(123 as unknown as string)).toBe(false); 98 + expect(isValidDocId({} as unknown as string)).toBe(false); 99 + expect(isValidDocId([] as unknown as string)).toBe(false); 100 + expect(isValidDocId(true as unknown as string)).toBe(false); 101 + }); 102 + 103 + it('rejects null bytes and control characters', () => { 104 + expect(isValidDocId('doc\x00id')).toBe(false); 105 + expect(isValidDocId('doc\nid')).toBe(false); 106 + expect(isValidDocId('doc\rid')).toBe(false); 107 + expect(isValidDocId('doc\tid')).toBe(false); 108 + }); 109 + }); 110 + 111 + // --- isValidDocType --- 112 + 113 + describe('isValidDocType', () => { 114 + it('accepts all valid types including calendar', () => { 115 + expect(isValidDocType('doc')).toBe(true); 116 + expect(isValidDocType('sheet')).toBe(true); 117 + expect(isValidDocType('form')).toBe(true); 118 + expect(isValidDocType('slide')).toBe(true); 119 + expect(isValidDocType('diagram')).toBe(true); 120 + expect(isValidDocType('calendar')).toBe(true); 121 + }); 122 + 123 + it('rejects invalid type strings', () => { 124 + expect(isValidDocType('spreadsheet')).toBe(false); 125 + expect(isValidDocType('text')).toBe(false); 126 + expect(isValidDocType('document')).toBe(false); 127 + expect(isValidDocType('')).toBe(false); 128 + expect(isValidDocType('DOC')).toBe(false); 129 + expect(isValidDocType('Doc')).toBe(false); 130 + }); 131 + 132 + it('rejects non-string types', () => { 133 + expect(isValidDocType(null)).toBe(false); 134 + expect(isValidDocType(undefined)).toBe(false); 135 + expect(isValidDocType(42)).toBe(false); 136 + expect(isValidDocType(true)).toBe(false); 137 + expect(isValidDocType({})).toBe(false); 138 + expect(isValidDocType([])).toBe(false); 139 + }); 140 + }); 141 + 142 + // --- isValidShareMode --- 143 + 144 + describe('isValidShareMode', () => { 145 + it('accepts edit and view', () => { 146 + expect(isValidShareMode('edit')).toBe(true); 147 + expect(isValidShareMode('view')).toBe(true); 148 + }); 149 + 150 + it('rejects other strings', () => { 151 + expect(isValidShareMode('admin')).toBe(false); 152 + expect(isValidShareMode('write')).toBe(false); 153 + expect(isValidShareMode('read')).toBe(false); 154 + expect(isValidShareMode('')).toBe(false); 155 + expect(isValidShareMode('EDIT')).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 + // --- isValidMimeType --- 167 + 168 + describe('isValidMimeType', () => { 169 + it('accepts standard MIME types', () => { 170 + expect(isValidMimeType('application/json')).toBe(true); 171 + expect(isValidMimeType('text/html')).toBe(true); 172 + expect(isValidMimeType('image/png')).toBe(true); 173 + expect(isValidMimeType('image/jpeg')).toBe(true); 174 + expect(isValidMimeType('application/octet-stream')).toBe(true); 175 + expect(isValidMimeType('application/pdf')).toBe(true); 176 + expect(isValidMimeType('audio/mpeg')).toBe(true); 177 + expect(isValidMimeType('video/mp4')).toBe(true); 178 + }); 179 + 180 + it('accepts MIME types with dots, plus, and hyphens', () => { 181 + expect(isValidMimeType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')).toBe(true); 182 + expect(isValidMimeType('application/ld+json')).toBe(true); 183 + expect(isValidMimeType('image/svg+xml')).toBe(true); 184 + expect(isValidMimeType('application/x-tar')).toBe(true); 185 + }); 186 + 187 + it('rejects MIME types without slash', () => { 188 + expect(isValidMimeType('texthtml')).toBe(false); 189 + expect(isValidMimeType('justtext')).toBe(false); 190 + }); 191 + 192 + it('rejects empty string', () => { 193 + expect(isValidMimeType('')).toBe(false); 194 + }); 195 + 196 + it('rejects strings with spaces', () => { 197 + expect(isValidMimeType('text / html')).toBe(false); 198 + expect(isValidMimeType('text/html ')).toBe(false); 199 + }); 200 + 201 + it('rejects injection attempts via parameters', () => { 202 + expect(isValidMimeType('text/html; charset=utf-8')).toBe(false); 203 + expect(isValidMimeType('text/html\nX-Injected: true')).toBe(false); 204 + expect(isValidMimeType('text/html\r\nContent-Length: 0')).toBe(false); 205 + }); 206 + 207 + it('rejects malicious strings', () => { 208 + expect(isValidMimeType('<script>')).toBe(false); 209 + expect(isValidMimeType("'; DROP TABLE--")).toBe(false); 210 + expect(isValidMimeType('../../../etc/passwd')).toBe(false); 211 + }); 212 + }); 213 + 214 + // --- RateLimiter --- 215 + 216 + describe('RateLimiter', () => { 217 + let limiter: RateLimiter; 218 + 219 + beforeEach(() => { 220 + limiter = new RateLimiter(); 221 + }); 222 + 223 + it('allows requests within the limit', () => { 224 + expect(limiter.check('user1', 3, 60000)).toBe(true); 225 + expect(limiter.check('user1', 3, 60000)).toBe(true); 226 + expect(limiter.check('user1', 3, 60000)).toBe(true); 227 + }); 228 + 229 + it('blocks at the limit boundary', () => { 230 + expect(limiter.check('user1', 2, 60000)).toBe(true); // 1st 231 + expect(limiter.check('user1', 2, 60000)).toBe(true); // 2nd (at limit) 232 + expect(limiter.check('user1', 2, 60000)).toBe(false); // 3rd (over limit) 233 + }); 234 + 235 + it('blocks all subsequent requests after limit', () => { 236 + limiter.check('user1', 1, 60000); 237 + expect(limiter.check('user1', 1, 60000)).toBe(false); 238 + expect(limiter.check('user1', 1, 60000)).toBe(false); 239 + expect(limiter.check('user1', 1, 60000)).toBe(false); 240 + }); 241 + 242 + it('tracks different keys independently', () => { 243 + limiter.check('user1', 1, 60000); 244 + expect(limiter.check('user1', 1, 60000)).toBe(false); 245 + expect(limiter.check('user2', 1, 60000)).toBe(true); 246 + expect(limiter.check('user3', 1, 60000)).toBe(true); 247 + }); 248 + 249 + it('resets after window expires', () => { 250 + limiter.check('user1', 1, 1); // 1ms window 251 + const start = Date.now(); 252 + while (Date.now() - start < 5) { /* spin */ } 253 + expect(limiter.check('user1', 1, 1)).toBe(true); 254 + }); 255 + 256 + it('cleanup removes expired entries', () => { 257 + limiter.check('user1', 1, 1); // 1ms window 258 + const start = Date.now(); 259 + while (Date.now() - start < 5) { /* spin */ } 260 + limiter.cleanup(); 261 + expect(limiter.check('user1', 1, 60000)).toBe(true); 262 + }); 263 + 264 + it('cleanup preserves non-expired entries', () => { 265 + limiter.check('user1', 1, 60000); // long window 266 + limiter.check('user2', 1, 1); // short window 267 + const start = Date.now(); 268 + while (Date.now() - start < 5) { /* spin */ } 269 + limiter.cleanup(); 270 + // user1 should still be blocked, user2 should be cleared 271 + expect(limiter.check('user1', 1, 60000)).toBe(false); 272 + expect(limiter.check('user2', 1, 60000)).toBe(true); 273 + }); 274 + 275 + it('clear removes all entries', () => { 276 + limiter.check('user1', 1, 60000); 277 + limiter.check('user2', 1, 60000); 278 + expect(limiter.check('user1', 1, 60000)).toBe(false); 279 + limiter.clear(); 280 + expect(limiter.check('user1', 1, 60000)).toBe(true); 281 + expect(limiter.check('user2', 1, 60000)).toBe(true); 282 + }); 283 + 284 + it('handles high limits correctly', () => { 285 + for (let i = 0; i < 100; i++) { 286 + expect(limiter.check('key', 100, 60000)).toBe(true); 287 + } 288 + expect(limiter.check('key', 100, 60000)).toBe(false); 289 + }); 290 + 291 + it('handles limit of 1', () => { 292 + expect(limiter.check('key', 1, 60000)).toBe(true); 293 + expect(limiter.check('key', 1, 60000)).toBe(false); 294 + }); 295 + }); 296 + 297 + // --- filterMetadata --- 298 + 299 + describe('filterMetadata', () => { 300 + it('extracts all allowed keys', () => { 301 + const result = filterMetadata({ 302 + label: 'v1', 303 + description: 'First version', 304 + starred: true, 305 + color: '#ff0000', 306 + tags: 'important', 307 + }); 308 + expect(result).toEqual({ 309 + label: 'v1', 310 + description: 'First version', 311 + starred: true, 312 + color: '#ff0000', 313 + tags: 'important', 314 + }); 315 + }); 316 + 317 + it('ignores disallowed keys', () => { 318 + const result = filterMetadata({ 319 + label: 'v1', 320 + admin: true, 321 + script: '<script>alert(1)</script>', 322 + __proto__: 'evil', 323 + constructor: 'bad', 324 + }); 325 + expect(result).toEqual({ label: 'v1' }); 326 + expect(result).not.toHaveProperty('admin'); 327 + expect(result).not.toHaveProperty('script'); 328 + }); 329 + 330 + it('returns empty object for non-object inputs', () => { 331 + expect(filterMetadata(null)).toEqual({}); 332 + expect(filterMetadata(undefined)).toEqual({}); 333 + expect(filterMetadata('string')).toEqual({}); 334 + expect(filterMetadata(42)).toEqual({}); 335 + expect(filterMetadata(true)).toEqual({}); 336 + }); 337 + 338 + it('returns empty object for arrays', () => { 339 + expect(filterMetadata([1, 2, 3])).toEqual({}); 340 + }); 341 + 342 + it('returns empty object for empty object', () => { 343 + expect(filterMetadata({})).toEqual({}); 344 + }); 345 + 346 + it('preserves falsy values for allowed keys', () => { 347 + const result = filterMetadata({ starred: false, label: '', color: null }); 348 + expect(result).toEqual({ starred: false, label: '', color: null }); 349 + }); 350 + }); 351 + 352 + // --- sanitizeAiRequest --- 353 + 354 + describe('sanitizeAiRequest', () => { 355 + it('passes through valid requests', () => { 356 + const result = sanitizeAiRequest({ 357 + model: 'gpt-4', 358 + messages: [{ role: 'user', content: 'Hello' }], 359 + temperature: 0.7, 360 + }); 361 + expect(result).toEqual({ 362 + model: 'gpt-4', 363 + messages: [{ role: 'user', content: 'Hello' }], 364 + temperature: 0.7, 365 + }); 366 + }); 367 + 368 + it('strips disallowed fields', () => { 369 + const result = sanitizeAiRequest({ 370 + messages: [{ role: 'user', content: 'Hello' }], 371 + api_key: 'sk-secret', 372 + headers: { Authorization: 'Bearer token' }, 373 + url: 'https://evil.com', 374 + }); 375 + expect(result).not.toHaveProperty('api_key'); 376 + expect(result).not.toHaveProperty('headers'); 377 + expect(result).not.toHaveProperty('url'); 378 + }); 379 + 380 + it('includes all allowed fields', () => { 381 + const body = { 382 + model: 'claude-3', 383 + messages: [{ role: 'user', content: 'Hi' }], 384 + temperature: 0.5, 385 + max_tokens: 100, 386 + stream: true, 387 + top_p: 0.9, 388 + stop: ['\n'], 389 + presence_penalty: 0.1, 390 + frequency_penalty: 0.2, 391 + }; 392 + expect(sanitizeAiRequest(body)).toEqual(body); 393 + }); 394 + 395 + it('returns null when messages is missing', () => { 396 + expect(sanitizeAiRequest({ model: 'gpt-4' })).toBeNull(); 397 + }); 398 + 399 + it('returns null when messages is not an array', () => { 400 + expect(sanitizeAiRequest({ messages: 'not an array' })).toBeNull(); 401 + expect(sanitizeAiRequest({ messages: { role: 'user' } })).toBeNull(); 402 + }); 403 + 404 + it('returns null for non-object inputs', () => { 405 + expect(sanitizeAiRequest(null)).toBeNull(); 406 + expect(sanitizeAiRequest(undefined)).toBeNull(); 407 + expect(sanitizeAiRequest('string')).toBeNull(); 408 + expect(sanitizeAiRequest(42)).toBeNull(); 409 + expect(sanitizeAiRequest([{ messages: [] }])).toBeNull(); 410 + }); 411 + });