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

Configure Feed

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

test: close remaining coverage gaps from QA audit round 2

Server route tests (21 new):
- Key sync HTTP routes: auth, 10KB limit, v2 format, legacy merge, corrupt data
- Share link expiry: 410 for expired docs on GET snapshot
- Snapshot rate limiting: 429 after 60 writes/min
- Version metadata update: auth check, merge, filterMetadata
- FIFO version pruning: prunes oldest when over 50 limit
- Health endpoint: authenticated vs anonymous response
- Blob Content-Disposition: attachment header, filename sanitization

Calendar tests (9 new):
- Yearly recurrence Feb 29 overflow: pins to Feb 28, recovers in leap years
- ICS export RRULE generation: daily, weekly, monthly, yearly with/without until

Bug fix:
- Yearly recurrence now uses same day-recovery as monthly (prevents
Feb 29 → Mar 1 permanent drift in non-leap years)

Refs: #585

+613 -5
+8 -1
src/calendar/helpers.ts
··· 233 233 next.setDate(Math.min(targetDay, maxDay)); 234 234 break; 235 235 } 236 - case 'yearly': 236 + case 'yearly': { 237 + const targetDay = startDay ?? next.getDate(); 238 + const targetMonth = next.getMonth(); 239 + next.setDate(1); // prevent day overflow (e.g. Feb 29 → Mar 1) 237 240 next.setFullYear(next.getFullYear() + 1); 241 + next.setMonth(targetMonth); 242 + const maxDay = new Date(next.getFullYear(), targetMonth + 1, 0).getDate(); 243 + next.setDate(Math.min(targetDay, maxDay)); 238 244 break; 245 + } 239 246 default: 240 247 next.setDate(next.getDate() + 1); 241 248 }
+63
tests/ics-export.test.ts
··· 372 372 expect(result.events[2]!.allDay).toBe(true); 373 373 }); 374 374 }); 375 + 376 + // --------------------------------------------------------------------------- 377 + // Recurrence rules (RRULE) 378 + // --------------------------------------------------------------------------- 379 + 380 + describe('exportIcsFile recurrence rules', () => { 381 + it('exports weekly recurrence as RRULE:FREQ=WEEKLY', () => { 382 + const events = [makeEvent({ 383 + recurrence: { type: 'weekly' }, 384 + })]; 385 + const ics = exportIcsFile(events, 'Cal'); 386 + const lines = icsLines(ics); 387 + expect(lines).toContain('RRULE:FREQ=WEEKLY'); 388 + }); 389 + 390 + it('exports monthly recurrence with until date as RRULE:FREQ=MONTHLY;UNTIL=YYYYMMDD', () => { 391 + const events = [makeEvent({ 392 + recurrence: { type: 'monthly', until: '2026-12-31' }, 393 + })]; 394 + const ics = exportIcsFile(events, 'Cal'); 395 + const lines = icsLines(ics); 396 + expect(lines).toContain('RRULE:FREQ=MONTHLY;UNTIL=20261231'); 397 + }); 398 + 399 + it('exports daily recurrence as RRULE:FREQ=DAILY', () => { 400 + const events = [makeEvent({ 401 + recurrence: { type: 'daily' }, 402 + })]; 403 + const ics = exportIcsFile(events, 'Cal'); 404 + const lines = icsLines(ics); 405 + expect(lines).toContain('RRULE:FREQ=DAILY'); 406 + }); 407 + 408 + it('exports yearly recurrence as RRULE:FREQ=YEARLY', () => { 409 + const events = [makeEvent({ 410 + recurrence: { type: 'yearly' }, 411 + })]; 412 + const ics = exportIcsFile(events, 'Cal'); 413 + const lines = icsLines(ics); 414 + expect(lines).toContain('RRULE:FREQ=YEARLY'); 415 + }); 416 + 417 + it('does not emit RRULE for non-recurring events', () => { 418 + const events = [makeEvent()]; 419 + const ics = exportIcsFile(events, 'Cal'); 420 + expect(ics).not.toContain('RRULE'); 421 + }); 422 + 423 + it('does not emit RRULE for recurrence type "none"', () => { 424 + const events = [makeEvent({ recurrence: { type: 'none' } })]; 425 + const ics = exportIcsFile(events, 'Cal'); 426 + expect(ics).not.toContain('RRULE'); 427 + }); 428 + 429 + it('exports weekly recurrence with until date', () => { 430 + const events = [makeEvent({ 431 + recurrence: { type: 'weekly', until: '2026-06-30' }, 432 + })]; 433 + const ics = exportIcsFile(events, 'Cal'); 434 + const lines = icsLines(ics); 435 + expect(lines).toContain('RRULE:FREQ=WEEKLY;UNTIL=20260630'); 436 + }); 437 + });
+45
tests/recurring-events.test.ts
··· 146 146 expect(result[0].date).toBe('2024-03-20'); 147 147 expect(result[3].date).toBe('2027-03-20'); 148 148 }); 149 + 150 + it('pins Feb 29 to Feb 28 in non-leap years and recovers in leap years', () => { 151 + const events = [makeEvent({ 152 + date: '2024-02-29', 153 + recurrence: { type: 'yearly' }, 154 + })]; 155 + const result = expandRecurringEvents(events, '2024-01-01', '2028-12-31'); 156 + 157 + // 2024: Feb 29 (correct — leap year, original date) 158 + expect(result[0].date).toBe('2024-02-29'); 159 + 160 + // 2025: Feb 29 does not exist → pins to Feb 28 161 + expect(result[1].date).toBe('2025-02-28'); 162 + 163 + // 2026: same, pins to Feb 28 164 + expect(result[2].date).toBe('2026-02-28'); 165 + 166 + // 2027: same 167 + expect(result[3].date).toBe('2027-02-28'); 168 + 169 + // 2028: leap year — recovers to Feb 29 170 + expect(result[4].date).toBe('2028-02-29'); 171 + }); 172 + 173 + it('recovers to Feb 29 in all future leap years', () => { 174 + const events = [makeEvent({ 175 + date: '2024-02-29', 176 + recurrence: { type: 'yearly' }, 177 + })]; 178 + const result = expandRecurringEvents(events, '2024-01-01', '2032-12-31'); 179 + 180 + const dates = result.map(e => e.date); 181 + 182 + expect(dates[0]).toBe('2024-02-29'); 183 + // Non-leap years pin to Feb 28 184 + expect(dates[1]).toBe('2025-02-28'); 185 + expect(dates[2]).toBe('2026-02-28'); 186 + expect(dates[3]).toBe('2027-02-28'); 187 + // Leap years recover to Feb 29 188 + expect(dates[4]).toBe('2028-02-29'); 189 + expect(dates[5]).toBe('2029-02-28'); 190 + expect(dates[6]).toBe('2030-02-28'); 191 + expect(dates[7]).toBe('2031-02-28'); 192 + expect(dates[8]).toBe('2032-02-29'); 193 + }); 149 194 }); 150 195 151 196 describe('multi-day recurring events', () => {
+497 -4
tests/server/routes.test.ts
··· 12 12 import express from 'express'; 13 13 import compression from 'compression'; 14 14 import type { Server } from 'http'; 15 + import { RateLimiter, filterMetadata } from '../../server/validation.js'; 15 16 16 17 let baseUrl: string; 17 18 let server: Server; ··· 129 130 next(); 130 131 }); 131 132 133 + // --- Key sync --- 134 + app.get('/api/keys', (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 135 + if (!req.tsUser) { res.status(403).json({ error: 'Authentication required' }); return; } 136 + const row = stmts.getKeys.get(req.tsUser.login) as { keys_json: string } | undefined; 137 + if (!row) { res.json({ keys: {} }); return; } 138 + try { 139 + res.json({ keys: JSON.parse(row.keys_json) }); 140 + } catch { 141 + res.status(500).json({ error: 'Failed to parse stored key data' }); 142 + } 143 + }); 144 + 145 + const MAX_KEY_PAYLOAD_BYTES = 10 * 1024; 146 + 147 + app.put('/api/keys', express.json({ limit: '1mb' }), (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 148 + if (!req.tsUser) { res.status(403).json({ error: 'Authentication required' }); return; } 149 + const bodySize = JSON.stringify(req.body).length; 150 + if (bodySize > MAX_KEY_PAYLOAD_BYTES) { 151 + res.status(413).json({ error: 'Key payload too large (max 10KB)' }); return; 152 + } 153 + const incoming = req.body?.keys; 154 + if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) { 155 + res.status(400).json({ error: 'keys must be an object' }); return; 156 + } 157 + // Wrapped format (v2) 158 + if (incoming.v === 2 && typeof incoming.salt === 'string' && typeof incoming.data === 'string') { 159 + stmts.putKeys.run(req.tsUser.login, JSON.stringify(incoming)); 160 + res.json({ ok: true }); 161 + return; 162 + } 163 + // Legacy plaintext format: validate and merge 164 + for (const [docId, keyStr] of Object.entries(incoming)) { 165 + if (typeof keyStr !== 'string' || (keyStr as string).length === 0) { 166 + res.status(400).json({ error: `Invalid key for doc ${docId}` }); return; 167 + } 168 + } 169 + const existing = stmts.getKeys.get(req.tsUser.login) as { keys_json: string } | undefined; 170 + let existingKeys: Record<string, unknown> = {}; 171 + if (existing) { 172 + try { existingKeys = JSON.parse(existing.keys_json) as Record<string, unknown>; } catch { /* overwrite */ } 173 + } 174 + const merged = { ...existingKeys, ...incoming }; 175 + stmts.putKeys.run(req.tsUser.login, JSON.stringify(merged)); 176 + res.json({ ok: true }); 177 + }); 178 + 132 179 // --- Document CRUD --- 133 180 app.post('/api/documents', (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 134 181 const id = randomUUID(); ··· 223 270 }); 224 271 225 272 // Snapshot handler 273 + const snapshotRateLimiter = new RateLimiter(); 226 274 const snapshotMw = express.raw({ limit: '50mb', type: '*/*' }); 227 275 const snapshotFn = (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 276 + const rlKey = `snap:${req.tsUser?.login || 'anon'}`; 277 + if (!snapshotRateLimiter.check(rlKey, 60, 60000)) { 278 + res.status(429).json({ error: 'Too many snapshot writes' }); 279 + return; 280 + } 228 281 if (!req.body || !Buffer.isBuffer(req.body) || req.body.length === 0) { 229 282 res.status(400).json({ error: 'Empty or missing snapshot body' }); 230 283 return; ··· 251 304 app.get('/api/documents/:id/snapshot', (req: Req, res: Res) => { 252 305 const row = stmts.getSnapshot.get(req.params.id) as { snapshot: Buffer | null; expires_at: string | null } | undefined; 253 306 if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; } 307 + if (row.expires_at) { 308 + const expiresAt = new Date(row.expires_at.endsWith('Z') ? row.expires_at : row.expires_at + 'Z'); 309 + if (expiresAt <= new Date()) { 310 + res.status(410).json({ error: 'Document link has expired' }); 311 + return; 312 + } 313 + } 254 314 res.type('application/octet-stream').send(row.snapshot); 255 315 }); 256 316 ··· 268 328 }); 269 329 270 330 // Versions 331 + const MAX_VERSIONS_PER_DOC = 50; 332 + 271 333 app.post('/api/documents/:id/versions', express.raw({ limit: '50mb', type: '*/*' }), (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 272 334 const docId = req.params.id; 273 335 const doc = stmts.getOne.get(docId) as Record<string, unknown> | undefined; ··· 276 338 return; 277 339 } 278 340 const id = randomUUID(); 279 - stmts.insertVersion.run(id, docId, req.body, null); 341 + const rawMetadata = req.headers['x-version-metadata'] as string | undefined ?? null; 342 + const metadata = rawMetadata && rawMetadata.length > 4096 ? rawMetadata.slice(0, 4096) : rawMetadata; 343 + stmts.insertVersion.run(id, docId, req.body, metadata); 344 + // FIFO: prune if over limit 345 + const countRow = stmts.countVersions.get(docId) as { count: number } | undefined; 346 + const count = countRow?.count ?? 0; 347 + if (count > MAX_VERSIONS_PER_DOC) { 348 + const excess = count - MAX_VERSIONS_PER_DOC; 349 + db.prepare('DELETE FROM versions WHERE id IN (SELECT id FROM versions WHERE document_id = ? ORDER BY created_at ASC, rowid ASC LIMIT ?)').run(docId, excess); 350 + } 280 351 res.json({ id }); 281 352 }); 282 353 283 354 app.get('/api/documents/:id/versions', (req: Req, res: Res) => { 284 - res.json(stmts.getVersions.all(req.params.id)); 355 + const versions = stmts.getVersions.all(req.params.id) as { id: string; document_id: string; created_at: string; metadata: string | null }[]; 356 + res.json(versions.map(v => ({ 357 + ...v, 358 + metadata: v.metadata ? (() => { try { return JSON.parse(v.metadata!) as unknown; } catch { return null; } })() : null, 359 + }))); 360 + }); 361 + 362 + app.put('/api/documents/:id/versions/:versionId/metadata', express.json(), (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 363 + const { id: docId, versionId } = req.params; 364 + const doc = stmts.getOne.get(docId) as Record<string, unknown> | undefined; 365 + if (doc?.owner && req.tsUser?.login !== doc.owner && doc.share_mode !== 'edit') { 366 + res.status(403).json({ error: 'Only the document owner can update version metadata' }); 367 + return; 368 + } 369 + const version = db.prepare('SELECT id, metadata FROM versions WHERE id = ? AND document_id = ?') 370 + .get(versionId, docId) as { id: string; metadata: string | null } | undefined; 371 + if (!version) { 372 + res.status(404).json({ error: 'Version not found' }); 373 + return; 374 + } 375 + let existing: Record<string, unknown> = {}; 376 + if (version.metadata) { 377 + try { existing = JSON.parse(version.metadata) as Record<string, unknown>; } catch { /* ignore */ } 378 + } 379 + const incoming = filterMetadata(req.body); 380 + const merged = { ...existing, ...incoming }; 381 + db.prepare('UPDATE versions SET metadata = ? WHERE id = ? AND document_id = ?') 382 + .run(JSON.stringify(merged), versionId, docId); 383 + res.json({ ok: true, metadata: merged }); 285 384 }); 286 385 287 386 // Blobs ··· 297 396 const BLOB_MAX_SIZE = 10 * 1024 * 1024; 298 397 if (data.length > BLOB_MAX_SIZE) return res.status(413).json({ error: 'Blob too large (max 10MB)' }); 299 398 const id = randomUUID(); 300 - stmts.insertBlob.run(id, docId, 'file', 'application/octet-stream', data.length, data); 399 + const fileName = (req.headers['x-file-name'] as string || 'file').slice(0, 255); 400 + stmts.insertBlob.run(id, docId, fileName, 'application/octet-stream', data.length, data); 301 401 res.status(201).json({ id, size: data.length }); 302 402 }); 303 403 304 404 app.get('/api/blobs/:id', (req: Req, res: Res) => { 305 405 const row = stmts.getBlob.get(req.params.id) as Record<string, unknown> | undefined; 306 406 if (!row) return res.status(404).json({ error: 'Not found' }); 407 + const safeName = (row.file_name as string).replace(/["\\\r\n]/g, '_'); 408 + res.set('Content-Disposition', `attachment; filename="${safeName}"`); 307 409 res.type(row.mime_type as string).send(row.data); 308 410 }); 309 411 ··· 320 422 }); 321 423 322 424 // Health 323 - app.get('/health', (_req: Req, res: Res) => res.json({ status: 'ok' })); 425 + app.get('/health', (req: Req & { tsUser?: { login: string } | null }, res: Res) => { 426 + try { 427 + db.prepare('SELECT 1').get(); 428 + if (req.tsUser) { 429 + const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; 430 + res.json({ status: 'ok', rooms: 0, users: userCount }); 431 + } else { 432 + res.json({ status: 'ok' }); 433 + } 434 + } catch { 435 + res.status(500).json({ status: 'error' }); 436 + } 437 + }); 324 438 325 439 server = createServer(app); 326 440 await new Promise<void>((resolve) => { ··· 932 1046 expect(res.status).toBe(400); 933 1047 }); 934 1048 }); 1049 + 1050 + // --- Key sync routes --- 1051 + 1052 + describe('key sync routes', () => { 1053 + it('GET /api/keys returns 403 for anonymous users', async () => { 1054 + const res = await fetch(`${baseUrl}/api/keys`); 1055 + expect(res.status).toBe(403); 1056 + const data = await res.json() as Record<string, unknown>; 1057 + expect(data.error).toBe('Authentication required'); 1058 + }); 1059 + 1060 + it('GET /api/keys returns empty keys for new authenticated user', async () => { 1061 + const res = await fetch(`${baseUrl}/api/keys`, { 1062 + headers: authHeaders('newuser@tailnet'), 1063 + }); 1064 + expect(res.status).toBe(200); 1065 + const data = await res.json() as { keys: Record<string, unknown> }; 1066 + expect(data.keys).toEqual({}); 1067 + }); 1068 + 1069 + it('PUT /api/keys stores and GET returns keys (legacy format)', async () => { 1070 + const login = 'keysync-legacy@tailnet'; 1071 + const putRes = await fetch(`${baseUrl}/api/keys`, { 1072 + method: 'PUT', 1073 + headers: { 'Content-Type': 'application/json', ...authHeaders(login) }, 1074 + body: JSON.stringify({ keys: { 'doc-1': 'aes-key-abc', 'doc-2': 'aes-key-def' } }), 1075 + }); 1076 + expect(putRes.status).toBe(200); 1077 + const putData = await putRes.json() as Record<string, unknown>; 1078 + expect(putData.ok).toBe(true); 1079 + 1080 + const getRes = await fetch(`${baseUrl}/api/keys`, { 1081 + headers: authHeaders(login), 1082 + }); 1083 + const getData = await getRes.json() as { keys: Record<string, string> }; 1084 + expect(getData.keys['doc-1']).toBe('aes-key-abc'); 1085 + expect(getData.keys['doc-2']).toBe('aes-key-def'); 1086 + }); 1087 + 1088 + it('PUT /api/keys stores v2 wrapped format as-is', async () => { 1089 + const login = 'keysync-v2@tailnet'; 1090 + const v2Keys = { v: 2, salt: 'random-salt-value', data: 'encrypted-blob-data' }; 1091 + const putRes = await fetch(`${baseUrl}/api/keys`, { 1092 + method: 'PUT', 1093 + headers: { 'Content-Type': 'application/json', ...authHeaders(login) }, 1094 + body: JSON.stringify({ keys: v2Keys }), 1095 + }); 1096 + expect(putRes.status).toBe(200); 1097 + 1098 + const getRes = await fetch(`${baseUrl}/api/keys`, { 1099 + headers: authHeaders(login), 1100 + }); 1101 + const getData = await getRes.json() as { keys: Record<string, unknown> }; 1102 + expect(getData.keys).toEqual(v2Keys); 1103 + }); 1104 + 1105 + it('PUT /api/keys merges with existing keys (legacy format)', async () => { 1106 + const login = 'keysync-merge@tailnet'; 1107 + // First PUT 1108 + await fetch(`${baseUrl}/api/keys`, { 1109 + method: 'PUT', 1110 + headers: { 'Content-Type': 'application/json', ...authHeaders(login) }, 1111 + body: JSON.stringify({ keys: { 'doc-a': 'key-a' } }), 1112 + }); 1113 + // Second PUT merges 1114 + await fetch(`${baseUrl}/api/keys`, { 1115 + method: 'PUT', 1116 + headers: { 'Content-Type': 'application/json', ...authHeaders(login) }, 1117 + body: JSON.stringify({ keys: { 'doc-b': 'key-b' } }), 1118 + }); 1119 + 1120 + const getRes = await fetch(`${baseUrl}/api/keys`, { 1121 + headers: authHeaders(login), 1122 + }); 1123 + const getData = await getRes.json() as { keys: Record<string, string> }; 1124 + expect(getData.keys['doc-a']).toBe('key-a'); 1125 + expect(getData.keys['doc-b']).toBe('key-b'); 1126 + }); 1127 + 1128 + it('PUT /api/keys returns 403 for anonymous', async () => { 1129 + const res = await fetch(`${baseUrl}/api/keys`, { 1130 + method: 'PUT', 1131 + headers: { 'Content-Type': 'application/json' }, 1132 + body: JSON.stringify({ keys: { 'doc-1': 'key' } }), 1133 + }); 1134 + expect(res.status).toBe(403); 1135 + }); 1136 + 1137 + it('PUT /api/keys returns 413 for payload over 10KB', async () => { 1138 + const bigKeys: Record<string, string> = {}; 1139 + for (let i = 0; i < 200; i++) { 1140 + bigKeys[`doc-${i}`] = 'x'.repeat(100); 1141 + } 1142 + const res = await fetch(`${baseUrl}/api/keys`, { 1143 + method: 'PUT', 1144 + headers: { 'Content-Type': 'application/json', ...authHeaders('bigpayload@tailnet') }, 1145 + body: JSON.stringify({ keys: bigKeys }), 1146 + }); 1147 + expect(res.status).toBe(413); 1148 + }); 1149 + 1150 + it('PUT /api/keys returns 400 for non-object keys', async () => { 1151 + const res = await fetch(`${baseUrl}/api/keys`, { 1152 + method: 'PUT', 1153 + headers: { 'Content-Type': 'application/json', ...authHeaders('badkeys@tailnet') }, 1154 + body: JSON.stringify({ keys: 'not-an-object' }), 1155 + }); 1156 + expect(res.status).toBe(400); 1157 + const data = await res.json() as Record<string, unknown>; 1158 + expect(data.error).toBe('keys must be an object'); 1159 + }); 1160 + 1161 + it('PUT /api/keys returns 400 for empty string key values', async () => { 1162 + const res = await fetch(`${baseUrl}/api/keys`, { 1163 + method: 'PUT', 1164 + headers: { 'Content-Type': 'application/json', ...authHeaders('emptyval@tailnet') }, 1165 + body: JSON.stringify({ keys: { 'doc-1': '' } }), 1166 + }); 1167 + expect(res.status).toBe(400); 1168 + const data = await res.json() as Record<string, unknown>; 1169 + expect(data.error).toContain('Invalid key for doc'); 1170 + }); 1171 + }); 1172 + 1173 + // --- Share link expiry --- 1174 + 1175 + describe('share link expiry', () => { 1176 + it('GET snapshot returns 410 for expired document', async () => { 1177 + const id = await createDoc('doc', 'alice@tailnet'); 1178 + // Save a snapshot 1179 + await fetch(`${baseUrl}/api/documents/${id}/snapshot`, { 1180 + method: 'PUT', 1181 + headers: { 'Content-Type': 'application/octet-stream', ...authHeaders('alice@tailnet') }, 1182 + body: new Uint8Array([10, 20, 30]), 1183 + }); 1184 + // Set expires_at to a past date directly in the DB 1185 + db.prepare("UPDATE documents SET expires_at = '2020-01-01T00:00:00Z' WHERE id = ?").run(id); 1186 + 1187 + const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`); 1188 + expect(res.status).toBe(410); 1189 + const data = await res.json() as Record<string, unknown>; 1190 + expect(data.error).toBe('Document link has expired'); 1191 + }); 1192 + 1193 + it('GET snapshot returns content for non-expired document', async () => { 1194 + const id = await createDoc('doc', 'alice@tailnet'); 1195 + await fetch(`${baseUrl}/api/documents/${id}/snapshot`, { 1196 + method: 'PUT', 1197 + headers: { 'Content-Type': 'application/octet-stream', ...authHeaders('alice@tailnet') }, 1198 + body: new Uint8Array([10, 20, 30]), 1199 + }); 1200 + // Set expires_at to a future date 1201 + db.prepare("UPDATE documents SET expires_at = '2099-12-31T23:59:59Z' WHERE id = ?").run(id); 1202 + 1203 + const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`); 1204 + expect(res.status).toBe(200); 1205 + const data = new Uint8Array(await res.arrayBuffer()); 1206 + expect(Array.from(data)).toEqual([10, 20, 30]); 1207 + }); 1208 + }); 1209 + 1210 + // --- Snapshot rate limiting --- 1211 + 1212 + describe('snapshot rate limiting', () => { 1213 + it('returns 429 after 60 rapid snapshot writes', async () => { 1214 + const id = await createDoc('doc', 'ratelimit-user@tailnet'); 1215 + const headers = { 1216 + 'Content-Type': 'application/octet-stream', 1217 + ...authHeaders('ratelimit-user@tailnet'), 1218 + }; 1219 + 1220 + // Send 60 requests (the limit) 1221 + for (let i = 0; i < 60; i++) { 1222 + const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`, { 1223 + method: 'PUT', 1224 + headers, 1225 + body: new Uint8Array([i]), 1226 + }); 1227 + expect(res.status).toBe(200); 1228 + } 1229 + 1230 + // The 61st should be rate-limited 1231 + const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`, { 1232 + method: 'PUT', 1233 + headers, 1234 + body: new Uint8Array([99]), 1235 + }); 1236 + expect(res.status).toBe(429); 1237 + const data = await res.json() as Record<string, unknown>; 1238 + expect(data.error).toContain('Too many snapshot writes'); 1239 + }); 1240 + }); 1241 + 1242 + // --- Version metadata update --- 1243 + 1244 + describe('version metadata update', () => { 1245 + it('updates version metadata with allowed fields', async () => { 1246 + const docId = await createDoc('doc', 'alice@tailnet'); 1247 + // Create a version 1248 + const vRes = await fetch(`${baseUrl}/api/documents/${docId}/versions`, { 1249 + method: 'POST', 1250 + headers: { 'Content-Type': 'application/octet-stream', ...authHeaders('alice@tailnet') }, 1251 + body: new Uint8Array([1, 2, 3]), 1252 + }); 1253 + const { id: versionId } = await vRes.json() as { id: string }; 1254 + 1255 + const res = await fetch(`${baseUrl}/api/documents/${docId}/versions/${versionId}/metadata`, { 1256 + method: 'PUT', 1257 + headers: { 'Content-Type': 'application/json', ...authHeaders('alice@tailnet') }, 1258 + body: JSON.stringify({ label: 'Draft v1', starred: true }), 1259 + }); 1260 + expect(res.status).toBe(200); 1261 + const data = await res.json() as { ok: boolean; metadata: Record<string, unknown> }; 1262 + expect(data.ok).toBe(true); 1263 + expect(data.metadata.label).toBe('Draft v1'); 1264 + expect(data.metadata.starred).toBe(true); 1265 + }); 1266 + 1267 + it('returns 404 for non-existent version', async () => { 1268 + const docId = await createDoc('doc', 'alice@tailnet'); 1269 + const res = await fetch(`${baseUrl}/api/documents/${docId}/versions/nonexistent/metadata`, { 1270 + method: 'PUT', 1271 + headers: { 'Content-Type': 'application/json', ...authHeaders('alice@tailnet') }, 1272 + body: JSON.stringify({ label: 'test' }), 1273 + }); 1274 + expect(res.status).toBe(404); 1275 + const data = await res.json() as Record<string, unknown>; 1276 + expect(data.error).toBe('Version not found'); 1277 + }); 1278 + 1279 + it('merges with existing metadata', async () => { 1280 + const docId = await createDoc('doc', 'alice@tailnet'); 1281 + const vRes = await fetch(`${baseUrl}/api/documents/${docId}/versions`, { 1282 + method: 'POST', 1283 + headers: { 'Content-Type': 'application/octet-stream', ...authHeaders('alice@tailnet') }, 1284 + body: new Uint8Array([1, 2, 3]), 1285 + }); 1286 + const { id: versionId } = await vRes.json() as { id: string }; 1287 + 1288 + // Set initial metadata 1289 + await fetch(`${baseUrl}/api/documents/${docId}/versions/${versionId}/metadata`, { 1290 + method: 'PUT', 1291 + headers: { 'Content-Type': 'application/json', ...authHeaders('alice@tailnet') }, 1292 + body: JSON.stringify({ label: 'First', color: 'red' }), 1293 + }); 1294 + 1295 + // Merge with new metadata 1296 + const res = await fetch(`${baseUrl}/api/documents/${docId}/versions/${versionId}/metadata`, { 1297 + method: 'PUT', 1298 + headers: { 'Content-Type': 'application/json', ...authHeaders('alice@tailnet') }, 1299 + body: JSON.stringify({ label: 'Updated', starred: true }), 1300 + }); 1301 + expect(res.status).toBe(200); 1302 + const data = await res.json() as { ok: boolean; metadata: Record<string, unknown> }; 1303 + expect(data.metadata.label).toBe('Updated'); 1304 + expect(data.metadata.color).toBe('red'); 1305 + expect(data.metadata.starred).toBe(true); 1306 + }); 1307 + 1308 + it('blocks non-owner on view-only doc', async () => { 1309 + const docId = await createDoc('doc', 'alice@tailnet'); 1310 + // Set to view-only 1311 + await fetch(`${baseUrl}/api/documents/${docId}/share`, { 1312 + method: 'PUT', 1313 + headers: { 'Content-Type': 'application/json', ...authHeaders('alice@tailnet') }, 1314 + body: JSON.stringify({ share_mode: 'view' }), 1315 + }); 1316 + // Create version as owner 1317 + const vRes = await fetch(`${baseUrl}/api/documents/${docId}/versions`, { 1318 + method: 'POST', 1319 + headers: { 'Content-Type': 'application/octet-stream', ...authHeaders('alice@tailnet') }, 1320 + body: new Uint8Array([1, 2, 3]), 1321 + }); 1322 + const { id: versionId } = await vRes.json() as { id: string }; 1323 + 1324 + // Non-owner tries to update metadata 1325 + const res = await fetch(`${baseUrl}/api/documents/${docId}/versions/${versionId}/metadata`, { 1326 + method: 'PUT', 1327 + headers: { 'Content-Type': 'application/json', ...authHeaders('eve@tailnet') }, 1328 + body: JSON.stringify({ label: 'hacked' }), 1329 + }); 1330 + expect(res.status).toBe(403); 1331 + }); 1332 + }); 1333 + 1334 + // --- FIFO version pruning --- 1335 + 1336 + describe('FIFO version pruning', () => { 1337 + it('prunes oldest versions when over 50 limit', async () => { 1338 + const docId = await createDoc('doc', 'alice@tailnet'); 1339 + const headers = { 1340 + 'Content-Type': 'application/octet-stream', 1341 + ...authHeaders('alice@tailnet'), 1342 + }; 1343 + 1344 + // Create 52 versions 1345 + for (let i = 0; i < 52; i++) { 1346 + const res = await fetch(`${baseUrl}/api/documents/${docId}/versions`, { 1347 + method: 'POST', 1348 + headers, 1349 + body: new Uint8Array([i]), 1350 + }); 1351 + expect(res.status).toBe(200); 1352 + } 1353 + 1354 + // Verify only 50 remain 1355 + const versRes = await fetch(`${baseUrl}/api/documents/${docId}/versions`); 1356 + const versions = await versRes.json() as unknown[]; 1357 + expect(versions.length).toBe(50); 1358 + 1359 + // Verify count in DB matches 1360 + const countRow = db.prepare('SELECT COUNT(*) as count FROM versions WHERE document_id = ?').get(docId) as { count: number }; 1361 + expect(countRow.count).toBe(50); 1362 + }); 1363 + }); 1364 + 1365 + // --- Health endpoint details --- 1366 + 1367 + describe('health endpoint details', () => { 1368 + it('returns rooms and users count for authenticated users', async () => { 1369 + // Ensure at least one user exists by making an authenticated request 1370 + await createDoc('doc', 'health-test-user@tailnet'); 1371 + 1372 + const res = await fetch(`${baseUrl}/health`, { 1373 + headers: authHeaders('health-test-user@tailnet'), 1374 + }); 1375 + expect(res.status).toBe(200); 1376 + const data = await res.json() as { status: string; rooms: number; users: number }; 1377 + expect(data.status).toBe('ok'); 1378 + expect(typeof data.rooms).toBe('number'); 1379 + expect(typeof data.users).toBe('number'); 1380 + expect(data.users).toBeGreaterThanOrEqual(1); 1381 + }); 1382 + 1383 + it('returns only status for anonymous users', async () => { 1384 + const res = await fetch(`${baseUrl}/health`); 1385 + expect(res.status).toBe(200); 1386 + const data = await res.json() as Record<string, unknown>; 1387 + expect(data.status).toBe('ok'); 1388 + expect(data.rooms).toBeUndefined(); 1389 + expect(data.users).toBeUndefined(); 1390 + }); 1391 + }); 1392 + 1393 + // --- Blob Content-Disposition --- 1394 + 1395 + describe('blob Content-Disposition', () => { 1396 + it('sets Content-Disposition: attachment header on blob download', async () => { 1397 + const docId = await createDoc('doc', 'alice@tailnet'); 1398 + const blobRes = await fetch(`${baseUrl}/api/blobs`, { 1399 + method: 'POST', 1400 + headers: { 1401 + 'Content-Type': 'application/octet-stream', 1402 + 'x-document-id': docId, 1403 + 'x-file-name': 'report.pdf', 1404 + ...authHeaders('alice@tailnet'), 1405 + }, 1406 + body: new Uint8Array([1, 2, 3]), 1407 + }); 1408 + const { id: blobId } = await blobRes.json() as { id: string }; 1409 + 1410 + const res = await fetch(`${baseUrl}/api/blobs/${blobId}`); 1411 + expect(res.status).toBe(200); 1412 + const disposition = res.headers.get('content-disposition'); 1413 + expect(disposition).toBe('attachment; filename="report.pdf"'); 1414 + }); 1415 + 1416 + it('sanitizes filename with special characters', async () => { 1417 + const docId = await createDoc('doc', 'alice@tailnet'); 1418 + // Insert directly into DB to bypass HTTP header restrictions on \r\n 1419 + const blobId = randomUUID(); 1420 + db.prepare('INSERT INTO blobs (id, document_id, file_name, mime_type, size, data) VALUES (?, ?, ?, ?, ?, ?)') 1421 + .run(blobId, docId, 'evil"file\\name\r\n.txt', 'application/octet-stream', 3, Buffer.from([4, 5, 6])); 1422 + 1423 + const res = await fetch(`${baseUrl}/api/blobs/${blobId}`); 1424 + const disposition = res.headers.get('content-disposition'); 1425 + expect(disposition).toBe('attachment; filename="evil_file_name__.txt"'); 1426 + }); 1427 + });