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

Configure Feed

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

fix: PWA/desktop polish + comprehensive QA suite (#226)

scott 9ff67194 9721e697

+3584 -22
public/icon-192.png

This is a binary file and will not be displayed.

public/icon-512.png

This is a binary file and will not be displayed.

+18 -3
public/manifest.json
··· 1 1 { 2 2 "name": "Tools — Encrypted Office", 3 3 "short_name": "Tools", 4 - "description": "E2EE collaborative docs and sheets", 4 + "description": "End-to-end encrypted collaborative docs, sheets, diagrams, slides, and forms", 5 5 "start_url": "/", 6 6 "display": "standalone", 7 7 "background_color": "#111111", 8 8 "theme_color": "#3a8a7a", 9 9 "icons": [ 10 - { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" } 11 - ] 10 + { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }, 11 + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, 12 + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } 13 + ], 14 + "shortcuts": [ 15 + { 16 + "name": "New Document", 17 + "url": "/?action=new-doc", 18 + "icons": [{ "src": "/favicon.svg", "sizes": "any" }] 19 + }, 20 + { 21 + "name": "New Spreadsheet", 22 + "url": "/?action=new-sheet", 23 + "icons": [{ "src": "/favicon.svg", "sizes": "any" }] 24 + } 25 + ], 26 + "categories": ["productivity", "utilities"] 12 27 }
+25 -4
src/css/app.css
··· 770 770 .doc-item:hover .doc-item-move, 771 771 .doc-item:hover .doc-item-tag-edit { opacity: 1; } 772 772 773 + /* Touch devices: always show action buttons (no hover state) */ 774 + @media (hover: none) { 775 + .doc-item-delete, 776 + .doc-item-duplicate, 777 + .doc-item-move, 778 + .doc-item-tag-edit { 779 + opacity: 0.7; 780 + } 781 + } 782 + 773 783 /* Star button */ 774 784 .doc-star { 775 785 font-size: 1.1rem; ··· 1301 1311 align-items: center; 1302 1312 gap: var(--space-md); 1303 1313 padding: var(--space-sm) var(--space-md); 1314 + padding-left: max(var(--space-md), env(safe-area-inset-left)); 1315 + padding-right: max(var(--space-md), env(safe-area-inset-right)); 1304 1316 border-bottom: 1px solid var(--color-border); 1305 1317 background: var(--color-surface); 1306 1318 flex-shrink: 0; ··· 1308 1320 1309 1321 /* Electron traffic light padding — macOS hiddenInset titlebar */ 1310 1322 .is-electron .app-topbar { 1311 - padding-left: 80px; 1323 + padding-left: 96px; 1324 + -webkit-app-region: drag; 1325 + } 1326 + 1327 + .is-electron .app-topbar button, 1328 + .is-electron .app-topbar input, 1329 + .is-electron .app-topbar a, 1330 + .is-electron .app-topbar select, 1331 + .is-electron .app-topbar .collab-avatars { 1332 + -webkit-app-region: no-drag; 1312 1333 } 1313 1334 1314 1335 .is-electron .brand { 1315 - padding-left: 72px; 1336 + padding-left: 88px; 1316 1337 } 1317 1338 1318 1339 .app-logo { ··· 7773 7794 7774 7795 /* ── Electron traffic-light padding ─────────────────────────────────── */ 7775 7796 .is-electron .app-topbar { 7776 - padding-left: 80px; 7797 + padding-left: 96px; 7777 7798 } 7778 7799 .is-electron .landing-header .brand { 7779 - padding-left: 72px; 7800 + padding-left: 88px; 7780 7801 } 7781 7802 7782 7803 /* ── Slides Editor ──────────────────────────────────────────────────── */
+1 -1
src/diagrams/index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, viewport-fit=cover"> 6 6 <link rel="manifest" href="/manifest.json"> 7 7 <meta name="description" content="E2EE diagrams and whiteboard. End-to-end encrypted, real-time collaboration."> 8 8 <title>Tools — Diagrams</title>
+31
src/diagrams/main.ts
··· 1342 1342 let lastTouchDist = 0; 1343 1343 let lastTouchCenter: Point = { x: 0, y: 0 }; 1344 1344 1345 + let touchPanning = false; 1346 + let touchPanStart: Point = { x: 0, y: 0 }; 1347 + let touchPanWbStart: Point = { x: 0, y: 0 }; 1348 + 1345 1349 canvas.addEventListener('touchstart', (e) => { 1346 1350 if (e.touches.length === 2) { 1351 + // Pinch-to-zoom 1347 1352 e.preventDefault(); 1353 + touchPanning = false; 1348 1354 const dx = e.touches[1].clientX - e.touches[0].clientX; 1349 1355 const dy = e.touches[1].clientY - e.touches[0].clientY; 1350 1356 lastTouchDist = Math.sqrt(dx * dx + dy * dy); ··· 1352 1358 x: (e.touches[0].clientX + e.touches[1].clientX) / 2, 1353 1359 y: (e.touches[0].clientY + e.touches[1].clientY) / 2, 1354 1360 }; 1361 + } else if (e.touches.length === 1 && (activeTool === 'hand' || activeTool === 'select')) { 1362 + // Single-finger pan (when hand tool active, or on empty canvas area with select) 1363 + const touch = e.touches[0]; 1364 + const pt = screenToCanvas(touch.clientX, touch.clientY); 1365 + const hitShape = shapeAtPoint(wb, pt.x, pt.y); 1366 + if (!hitShape || activeTool === 'hand') { 1367 + e.preventDefault(); 1368 + touchPanning = true; 1369 + touchPanStart = { x: touch.clientX, y: touch.clientY }; 1370 + touchPanWbStart = { x: wb.panX, y: wb.panY }; 1371 + } 1355 1372 } 1356 1373 }, { passive: false }); 1357 1374 1358 1375 canvas.addEventListener('touchmove', (e) => { 1359 1376 if (e.touches.length === 2) { 1377 + // Pinch-to-zoom 1360 1378 e.preventDefault(); 1379 + touchPanning = false; 1361 1380 const dx = e.touches[1].clientX - e.touches[0].clientX; 1362 1381 const dy = e.touches[1].clientY - e.touches[0].clientY; 1363 1382 const dist = Math.sqrt(dx * dx + dy * dy); ··· 1372 1391 wb = { ...wb, panX: wb.panX + (center.x - lastTouchCenter.x), panY: wb.panY + (center.y - lastTouchCenter.y) }; 1373 1392 lastTouchCenter = center; 1374 1393 render(); 1394 + } else if (e.touches.length === 1 && touchPanning) { 1395 + // Single-finger pan 1396 + e.preventDefault(); 1397 + const touch = e.touches[0]; 1398 + const dx = touch.clientX - touchPanStart.x; 1399 + const dy = touch.clientY - touchPanStart.y; 1400 + wb = { ...wb, panX: touchPanWbStart.x + dx, panY: touchPanWbStart.y + dy }; 1401 + render(); 1375 1402 } 1376 1403 }, { passive: false }); 1404 + 1405 + canvas.addEventListener('touchend', () => { 1406 + touchPanning = false; 1407 + }); 1377 1408 1378 1409 // --- Tool buttons --- 1379 1410 document.querySelectorAll('.diagrams-tool').forEach(btn => {
+1 -1
src/docs/index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, viewport-fit=cover"> 6 6 <link rel="manifest" href="/manifest.json"> 7 7 <meta name="description" content="E2EE collaborative document editor. End-to-end encrypted, real-time collaboration."> 8 8 <meta property="og:title" content="Tools — Docs">
+1 -1
src/forms/index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, viewport-fit=cover"> 6 6 <link rel="manifest" href="/manifest.json"> 7 7 <meta name="description" content="E2EE form builder. End-to-end encrypted, real-time collaboration."> 8 8 <title>Tools — Forms</title>
+3 -1
src/index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, viewport-fit=cover"> 6 6 <link rel="manifest" href="/manifest.json"> 7 7 <meta name="description" content="E2EE collaborative docs and sheets. End-to-end encrypted, real-time collaboration."> 8 8 <meta property="og:title" content="Tools — Encrypted Office"> ··· 100 100 <svg class="view-icon view-icon-list" viewBox="0 0 16 16" width="16" height="16"><rect x="1" y="2" width="14" height="2.5" rx="0.5"/><rect x="1" y="6.75" width="14" height="2.5" rx="0.5"/><rect x="1" y="11.5" width="14" height="2.5" rx="0.5"/></svg> 101 101 </button> 102 102 <button class="btn-secondary" id="new-folder-btn" title="New Folder">+ Folder</button> 103 + <button class="btn-secondary" id="file-import-btn" title="Import .docx, .xlsx, .csv, or .md file">&#8679; Import</button> 104 + <input type="file" id="file-import-input" accept=".docx,.xlsx,.xls,.csv,.tsv,.md,.txt" style="display:none"> 103 105 <button class="btn-secondary" id="backup-export-btn" title="Export backup">&#8681; Backup</button> 104 106 <button class="btn-secondary" id="backup-import-btn" title="Import backup">&#8679; Restore</button> 105 107 <input type="file" id="backup-import-input" accept=".json" style="display:none">
+23 -8
src/landing.ts
··· 1164 1164 } 1165 1165 }); 1166 1166 1167 - document.addEventListener('drop', async (e) => { 1168 - e.preventDefault(); 1169 - dragCounter = 0; 1170 - hideDropOverlay(); 1171 - 1172 - const file = e.dataTransfer.files[0]; 1173 - if (!file) return; 1174 - 1167 + async function importFile(file: File) { 1175 1168 const docType = getFileType(file.name); 1176 1169 const importType = getImportType(file.name); 1177 1170 ··· 1232 1225 } catch (err) { 1233 1226 showToast('Failed to create document for import', 4000, true); 1234 1227 } 1228 + } 1229 + 1230 + document.addEventListener('drop', async (e) => { 1231 + e.preventDefault(); 1232 + dragCounter = 0; 1233 + hideDropOverlay(); 1234 + 1235 + const file = e.dataTransfer?.files[0]; 1236 + if (!file) return; 1237 + importFile(file); 1235 1238 }); 1239 + 1240 + // --- File import button (mobile-friendly alternative to drag-drop) --- 1241 + const fileImportBtn = document.getElementById('file-import-btn'); 1242 + const fileImportInput = document.getElementById('file-import-input') as HTMLInputElement | null; 1243 + if (fileImportBtn && fileImportInput) { 1244 + fileImportBtn.addEventListener('click', () => fileImportInput.click()); 1245 + fileImportInput.addEventListener('change', () => { 1246 + const file = fileImportInput.files?.[0]; 1247 + if (file) importFile(file); 1248 + fileImportInput.value = ''; 1249 + }); 1250 + } 1236 1251 1237 1252 // --- Command Palette --- 1238 1253 createCommandPalette({
+1 -1
src/sheets/index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, viewport-fit=cover"> 6 6 <link rel="manifest" href="/manifest.json"> 7 7 <meta name="description" content="E2EE collaborative spreadsheet. End-to-end encrypted, real-time collaboration."> 8 8 <meta property="og:title" content="Tools — Sheets">
+1 -1
src/slides/index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, viewport-fit=cover"> 6 6 <link rel="manifest" href="/manifest.json"> 7 7 <meta name="description" content="E2EE slide presentations. End-to-end encrypted, real-time collaboration."> 8 8 <title>Tools — Slides</title>
+37 -1
src/slides/main.ts
··· 381 381 } 382 382 }); 383 383 384 - // Drag handling 384 + // Drag handling (mouse) 385 385 document.addEventListener('mousemove', (e) => { 386 386 if (!isDragging || !selectedElementId) return; 387 387 const dx = e.clientX - dragStartX; ··· 391 391 }); 392 392 393 393 document.addEventListener('mouseup', () => { 394 + if (isDragging) { 395 + isDragging = false; 396 + syncDeckToYjs(); 397 + } 398 + }); 399 + 400 + // Drag handling (touch) 401 + slideCanvas.addEventListener('touchstart', (e) => { 402 + if (e.touches.length !== 1) return; 403 + const touch = e.touches[0]; 404 + const target = document.elementFromPoint(touch.clientX, touch.clientY)?.closest('[data-element-id]') as HTMLElement | null; 405 + if (!target) return; 406 + const elId = target.dataset.elementId!; 407 + const el = currentSlide(deck).elements.find(el => el.id === elId); 408 + if (!el) return; 409 + e.preventDefault(); 410 + selectedElementId = elId; 411 + isDragging = true; 412 + dragStartX = touch.clientX; 413 + dragStartY = touch.clientY; 414 + dragElStartX = el.x; 415 + dragElStartY = el.y; 416 + renderCanvas(); 417 + }, { passive: false }); 418 + 419 + document.addEventListener('touchmove', (e) => { 420 + if (!isDragging || !selectedElementId) return; 421 + const touch = e.touches[0]; 422 + const dx = touch.clientX - dragStartX; 423 + const dy = touch.clientY - dragStartY; 424 + deck = moveElement(deck, selectedElementId, dragElStartX + dx, dragElStartY + dy); 425 + renderCanvas(); 426 + e.preventDefault(); 427 + }, { passive: false }); 428 + 429 + document.addEventListener('touchend', () => { 394 430 if (isDragging) { 395 431 isDragging = false; 396 432 syncDeckToYjs();
+337
tests/canvas-engine-integrity.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createDeck, 4 + addSlide, 5 + removeSlide, 6 + duplicateSlide, 7 + goToSlide, 8 + moveSlide, 9 + addElement, 10 + removeElement, 11 + currentSlide, 12 + slideCount, 13 + elementCount, 14 + } from '../src/slides/canvas-engine.js'; 15 + 16 + // ===================================================================== 17 + // 1. ADD SLIDE — currentSlide index behavior 18 + // ===================================================================== 19 + 20 + describe('Canvas engine integrity — addSlide updates', () => { 21 + it('adding a slide does not change currentSlide index', () => { 22 + let deck = createDeck(); 23 + expect(deck.currentSlide).toBe(0); 24 + 25 + deck = addSlide(deck); 26 + // currentSlide should still be 0 (pointing to the original first slide) 27 + expect(deck.currentSlide).toBe(0); 28 + expect(slideCount(deck)).toBe(2); 29 + }); 30 + 31 + it('adding a slide at index 0 pushes the original slide to index 1', () => { 32 + let deck = createDeck(); 33 + const originalId = deck.slides[0].id; 34 + 35 + deck = addSlide(deck, 0); 36 + expect(deck.slides[1].id).toBe(originalId); 37 + expect(slideCount(deck)).toBe(2); 38 + }); 39 + 40 + it('currentSlide still points to a valid slide after adding at end', () => { 41 + let deck = createDeck(); 42 + deck = goToSlide(deck, 0); 43 + deck = addSlide(deck); 44 + deck = addSlide(deck); 45 + 46 + expect(deck.currentSlide).toBeLessThan(slideCount(deck)); 47 + expect(currentSlide(deck)).toBeDefined(); 48 + expect(currentSlide(deck).id).toBeTruthy(); 49 + }); 50 + }); 51 + 52 + // ===================================================================== 53 + // 2. DELETE ONLY SLIDE — cannot delete the last slide 54 + // ===================================================================== 55 + 56 + describe('Canvas engine integrity — delete only slide', () => { 57 + it('removing the only slide returns unchanged state', () => { 58 + const deck = createDeck(); 59 + expect(slideCount(deck)).toBe(1); 60 + 61 + const updated = removeSlide(deck, 0); 62 + expect(slideCount(updated)).toBe(1); 63 + // State reference should be the same (no mutation) 64 + expect(updated).toBe(deck); 65 + }); 66 + 67 + it('after removing one of two slides, currentSlide is clamped', () => { 68 + let deck = createDeck(); 69 + deck = addSlide(deck); 70 + deck = goToSlide(deck, 1); // on the second slide 71 + 72 + const updated = removeSlide(deck, 1); 73 + expect(slideCount(updated)).toBe(1); 74 + expect(updated.currentSlide).toBe(0); 75 + }); 76 + 77 + it('removing the first slide when on first slide clamps to 0', () => { 78 + let deck = createDeck(); 79 + deck = addSlide(deck); 80 + deck = goToSlide(deck, 0); 81 + 82 + const updated = removeSlide(deck, 0); 83 + expect(updated.currentSlide).toBe(0); 84 + expect(slideCount(updated)).toBe(1); 85 + }); 86 + }); 87 + 88 + // ===================================================================== 89 + // 3. ADD ELEMENT TO EMPTY DECK — works on the default first slide 90 + // ===================================================================== 91 + 92 + describe('Canvas engine integrity — add element to empty deck', () => { 93 + it('addElement works on the default first slide of a new deck', () => { 94 + let deck = createDeck(); 95 + expect(elementCount(deck)).toBe(0); 96 + 97 + deck = addElement(deck, 'text', 10, 20, 200, 100, 'Hello'); 98 + expect(elementCount(deck)).toBe(1); 99 + 100 + const el = currentSlide(deck).elements[0]; 101 + expect(el.type).toBe('text'); 102 + expect(el.content).toBe('Hello'); 103 + }); 104 + 105 + it('element is added to the current slide, not all slides', () => { 106 + let deck = createDeck(); 107 + deck = addSlide(deck); 108 + 109 + // Current slide is 0 110 + deck = addElement(deck, 'text', 0, 0, 100, 50, 'On slide 0'); 111 + 112 + expect(deck.slides[0].elements).toHaveLength(1); 113 + expect(deck.slides[1].elements).toHaveLength(0); 114 + }); 115 + 116 + it('adding element to second slide only affects that slide', () => { 117 + let deck = createDeck(); 118 + deck = addSlide(deck); 119 + deck = goToSlide(deck, 1); 120 + 121 + deck = addElement(deck, 'image', 0, 0, 300, 200, 'image.png'); 122 + 123 + expect(deck.slides[0].elements).toHaveLength(0); 124 + expect(deck.slides[1].elements).toHaveLength(1); 125 + }); 126 + }); 127 + 128 + // ===================================================================== 129 + // 4. ELEMENT POSITIONING AFTER SLIDE REORDER 130 + // ===================================================================== 131 + 132 + describe('Canvas engine integrity — element positioning after reorder', () => { 133 + it('moving a slide preserves its elements', () => { 134 + let deck = createDeck(); 135 + deck = addElement(deck, 'text', 10, 20, 100, 50, 'Slide 0 text'); 136 + deck = addSlide(deck); 137 + deck = goToSlide(deck, 1); 138 + deck = addElement(deck, 'image', 30, 40, 200, 150, 'image.png'); 139 + 140 + // Slide 0 has text, Slide 1 has image 141 + expect(deck.slides[0].elements[0].content).toBe('Slide 0 text'); 142 + expect(deck.slides[1].elements[0].content).toBe('image.png'); 143 + 144 + // Move slide 0 to position 1 145 + deck = moveSlide(deck, 0, 1); 146 + 147 + // Now slide at index 0 was the old slide 1 (image), and slide at index 1 is old slide 0 (text) 148 + expect(deck.slides[0].elements[0].content).toBe('image.png'); 149 + expect(deck.slides[1].elements[0].content).toBe('Slide 0 text'); 150 + }); 151 + 152 + it('reordering slides does not lose any elements', () => { 153 + let deck = createDeck(); 154 + deck = addElement(deck, 'text', 0, 0, 100, 50, 'el1'); 155 + deck = addSlide(deck); 156 + deck = goToSlide(deck, 1); 157 + deck = addElement(deck, 'text', 0, 0, 100, 50, 'el2'); 158 + deck = addSlide(deck); 159 + deck = goToSlide(deck, 2); 160 + deck = addElement(deck, 'text', 0, 0, 100, 50, 'el3'); 161 + 162 + // Move slide 2 to position 0 163 + deck = moveSlide(deck, 2, 0); 164 + 165 + // Count total elements across all slides 166 + const totalElements = deck.slides.reduce((sum, s) => sum + s.elements.length, 0); 167 + expect(totalElements).toBe(3); 168 + 169 + // Verify all content is preserved 170 + const contents = deck.slides.flatMap(s => s.elements.map(e => e.content)).sort(); 171 + expect(contents).toEqual(['el1', 'el2', 'el3']); 172 + }); 173 + }); 174 + 175 + // ===================================================================== 176 + // 5. DUPLICATE SLIDE PRESERVES ALL ELEMENTS 177 + // ===================================================================== 178 + 179 + describe('Canvas engine integrity — duplicate slide', () => { 180 + it('duplicated slide has same number of elements', () => { 181 + let deck = createDeck(); 182 + deck = addElement(deck, 'text', 10, 20, 100, 50, 'Hello'); 183 + deck = addElement(deck, 'image', 30, 40, 200, 150, 'photo.jpg'); 184 + deck = addElement(deck, 'shape', 50, 60, 80, 80, '', { fill: 'red' }); 185 + 186 + deck = duplicateSlide(deck, 0); 187 + expect(slideCount(deck)).toBe(2); 188 + expect(deck.slides[1].elements).toHaveLength(3); 189 + }); 190 + 191 + it('duplicated elements have different IDs from originals', () => { 192 + let deck = createDeck(); 193 + deck = addElement(deck, 'text', 0, 0, 100, 50, 'content'); 194 + deck = addElement(deck, 'image', 0, 0, 100, 50, 'img'); 195 + 196 + deck = duplicateSlide(deck, 0); 197 + 198 + const originalIds = deck.slides[0].elements.map(e => e.id); 199 + const duplicateIds = deck.slides[1].elements.map(e => e.id); 200 + 201 + for (const oid of originalIds) { 202 + expect(duplicateIds).not.toContain(oid); 203 + } 204 + }); 205 + 206 + it('duplicated elements preserve content, type, position, and style', () => { 207 + let deck = createDeck(); 208 + deck = addElement(deck, 'text', 10, 20, 200, 100, 'Test Content', { color: 'blue', fontSize: '24px' }); 209 + 210 + deck = duplicateSlide(deck, 0); 211 + 212 + const original = deck.slides[0].elements[0]; 213 + const copy = deck.slides[1].elements[0]; 214 + 215 + expect(copy.content).toBe(original.content); 216 + expect(copy.type).toBe(original.type); 217 + expect(copy.x).toBe(original.x); 218 + expect(copy.y).toBe(original.y); 219 + expect(copy.width).toBe(original.width); 220 + expect(copy.height).toBe(original.height); 221 + expect(copy.style).toEqual(original.style); 222 + }); 223 + 224 + it('duplicated slide preserves notes', () => { 225 + let deck = createDeck(); 226 + deck = { 227 + ...deck, 228 + slides: deck.slides.map(s => ({ ...s, notes: 'Speaker notes here' })), 229 + }; 230 + 231 + deck = duplicateSlide(deck, 0); 232 + expect(deck.slides[1].notes).toBe('Speaker notes here'); 233 + }); 234 + 235 + it('duplicated slide preserves background color', () => { 236 + let deck = createDeck(); 237 + deck = addSlide(deck, undefined, '#ff0000'); 238 + 239 + deck = duplicateSlide(deck, 1); 240 + expect(deck.slides[2].background).toBe('#ff0000'); 241 + }); 242 + }); 243 + 244 + // ===================================================================== 245 + // 6. LARGE NUMBER OF ELEMENTS PER SLIDE (100+) 246 + // ===================================================================== 247 + 248 + describe('Canvas engine integrity — many elements per slide', () => { 249 + it('adding 100 elements to a single slide works correctly', () => { 250 + let deck = createDeck(); 251 + for (let i = 0; i < 100; i++) { 252 + deck = addElement(deck, 'text', i * 10, i * 5, 50, 30, `Element ${i}`); 253 + } 254 + expect(elementCount(deck)).toBe(100); 255 + }); 256 + 257 + it('all 100 elements have unique IDs', () => { 258 + let deck = createDeck(); 259 + for (let i = 0; i < 100; i++) { 260 + deck = addElement(deck, 'text', i * 10, i * 5, 50, 30); 261 + } 262 + 263 + const ids = currentSlide(deck).elements.map(e => e.id); 264 + const uniqueIds = new Set(ids); 265 + expect(uniqueIds.size).toBe(100); 266 + }); 267 + 268 + it('removing one element from 100 leaves 99', () => { 269 + let deck = createDeck(); 270 + for (let i = 0; i < 100; i++) { 271 + deck = addElement(deck, 'text', i * 10, i * 5, 50, 30, `Element ${i}`); 272 + } 273 + 274 + const firstId = currentSlide(deck).elements[0].id; 275 + deck = removeElement(deck, firstId); 276 + expect(elementCount(deck)).toBe(99); 277 + 278 + // Verify the removed element is really gone 279 + const remaining = currentSlide(deck).elements.find(e => e.id === firstId); 280 + expect(remaining).toBeUndefined(); 281 + }); 282 + 283 + it('element zIndex increments correctly for 100 elements', () => { 284 + let deck = createDeck(); 285 + for (let i = 0; i < 100; i++) { 286 + deck = addElement(deck, 'text', 0, 0, 50, 30); 287 + } 288 + 289 + const elements = currentSlide(deck).elements; 290 + for (let i = 0; i < elements.length; i++) { 291 + expect(elements[i].zIndex).toBe(i); 292 + } 293 + }); 294 + }); 295 + 296 + // ===================================================================== 297 + // 7. EDGE CASES — goToSlide clamping, removeElement on wrong slide 298 + // ===================================================================== 299 + 300 + describe('Canvas engine integrity — edge cases', () => { 301 + it('goToSlide with negative index clamps to 0', () => { 302 + const deck = createDeck(); 303 + const updated = goToSlide(deck, -10); 304 + expect(updated.currentSlide).toBe(0); 305 + }); 306 + 307 + it('goToSlide with index beyond last slide clamps to last', () => { 308 + let deck = createDeck(); 309 + deck = addSlide(deck); 310 + const updated = goToSlide(deck, 999); 311 + expect(updated.currentSlide).toBe(1); 312 + }); 313 + 314 + it('removeElement with non-existent ID does not change element count', () => { 315 + let deck = createDeck(); 316 + deck = addElement(deck, 'text', 0, 0, 100, 50, 'test'); 317 + const before = elementCount(deck); 318 + 319 + deck = removeElement(deck, 'non-existent-id'); 320 + expect(elementCount(deck)).toBe(before); 321 + }); 322 + 323 + it('duplicateSlide with invalid index returns unchanged state', () => { 324 + const deck = createDeck(); 325 + const updated = duplicateSlide(deck, 99); 326 + expect(updated).toBe(deck); 327 + }); 328 + 329 + it('removeSlide with out-of-range index still works gracefully', () => { 330 + let deck = createDeck(); 331 + deck = addSlide(deck); 332 + // Index 5 is out of range for a 2-slide deck 333 + const updated = removeSlide(deck, 5); 334 + // filter will not find index 5, so all slides remain 335 + expect(slideCount(updated)).toBe(2); 336 + }); 337 + });
+232
tests/crypto-edge-cases.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + generateKey, exportKey, importKey, 4 + encrypt, decrypt, encryptString, decryptString, 5 + generateId, buildHash, 6 + } from '../src/lib/crypto.js'; 7 + 8 + /** 9 + * Crypto edge-case tests — supplements the 20 tests in crypto.test.ts. 10 + * Focuses on boundary values, error paths, and security properties. 11 + */ 12 + 13 + describe('crypto edge cases: empty and large payloads', () => { 14 + it('encrypts and decrypts an empty Uint8Array', async () => { 15 + const key = await generateKey(); 16 + const empty = new Uint8Array(0); 17 + const ciphertext = await encrypt(empty, key); 18 + // Even empty plaintext produces IV + auth tag 19 + expect(ciphertext.length).toBeGreaterThan(0); 20 + const decrypted = await decrypt(ciphertext, key); 21 + expect(decrypted.length).toBe(0); 22 + }); 23 + 24 + it('encrypts and decrypts a 100KB payload', async () => { 25 + const key = await generateKey(); 26 + const size = 100 * 1024; // 100KB 27 + const plaintext = new Uint8Array(size); 28 + for (let i = 0; i < size; i++) plaintext[i] = i % 256; 29 + 30 + const ciphertext = await encrypt(plaintext, key); 31 + const decrypted = await decrypt(ciphertext, key); 32 + expect(decrypted.length).toBe(size); 33 + // Spot-check boundaries 34 + expect(decrypted[0]).toBe(0); 35 + expect(decrypted[size - 1]).toBe((size - 1) % 256); 36 + expect(Array.from(decrypted)).toEqual(Array.from(plaintext)); 37 + }); 38 + 39 + it('encrypts and decrypts a 500KB string payload', async () => { 40 + const key = await generateKey(); 41 + const chunk = 'abcdefghijklmnopqrstuvwxyz0123456789'; 42 + const largeString = chunk.repeat(Math.ceil(500_000 / chunk.length)); 43 + 44 + const encrypted = await encryptString(largeString, key); 45 + const decrypted = await decryptString(encrypted, key); 46 + expect(decrypted).toBe(largeString); 47 + }); 48 + }); 49 + 50 + describe('key import error handling', () => { 51 + it('throws on malformed base64 (invalid characters)', async () => { 52 + await expect(importKey('!!!not-valid-base64!!!')).rejects.toThrow(); 53 + }); 54 + 55 + it('throws on empty string', async () => { 56 + // Empty key data should fail (wrong key length) 57 + await expect(importKey('')).rejects.toThrow(); 58 + }); 59 + 60 + it('throws on truncated key (too short)', async () => { 61 + const key = await generateKey(); 62 + const exported = await exportKey(key); 63 + const truncated = exported.slice(0, 10); 64 + await expect(importKey(truncated)).rejects.toThrow(); 65 + }); 66 + 67 + it('throws on key with wrong length (too long)', async () => { 68 + const key = await generateKey(); 69 + const exported = await exportKey(key); 70 + // Double the key string to create wrong-length data 71 + await expect(importKey(exported + exported)).rejects.toThrow(); 72 + }); 73 + }); 74 + 75 + describe('generateId uniqueness', () => { 76 + it('produces 100 unique values', () => { 77 + const ids = new Set<string>(); 78 + for (let i = 0; i < 100; i++) { 79 + ids.add(generateId()); 80 + } 81 + expect(ids.size).toBe(100); 82 + }); 83 + 84 + it('all generated IDs are 32-char hex-like strings', () => { 85 + for (let i = 0; i < 50; i++) { 86 + const id = generateId(); 87 + expect(id.length).toBe(32); 88 + expect(id).toMatch(/^[0-9a-f]{32}$/); 89 + } 90 + }); 91 + }); 92 + 93 + describe('parseHash edge cases', () => { 94 + // parseHash reads location.hash which we cannot easily mock in vitest 95 + // without a DOM environment. Instead we test buildHash round-trip logic 96 + // and the format contract. 97 + 98 + it('buildHash produces the expected #docId/key format', async () => { 99 + const key = await generateKey(); 100 + const keyStr = await exportKey(key); 101 + const hash = await buildHash('abc123', key); 102 + expect(hash).toBe(`#abc123/${keyStr}`); 103 + }); 104 + 105 + it('buildHash with empty docId still creates valid format', async () => { 106 + const key = await generateKey(); 107 + const hash = await buildHash('', key); 108 + expect(hash.startsWith('#/')).toBe(true); 109 + }); 110 + 111 + it('buildHash → manual parseHash round-trip preserves docId and key', async () => { 112 + const key = await generateKey(); 113 + const docId = 'test_doc_42'; 114 + const hash = await buildHash(docId, key); 115 + 116 + // Manually parse the hash the same way parseHash does 117 + const raw = hash.slice(1); // remove # 118 + const slash = raw.indexOf('/'); 119 + expect(slash).toBeGreaterThan(-1); 120 + const parsedDocId = raw.slice(0, slash); 121 + const parsedKeyStr = raw.slice(slash + 1); 122 + 123 + expect(parsedDocId).toBe(docId); 124 + // Reimport and verify key is functionally identical 125 + const reimportedKey = await importKey(parsedKeyStr); 126 + const reexported = await exportKey(reimportedKey); 127 + expect(reexported).toBe(await exportKey(key)); 128 + }); 129 + 130 + it('buildHash → parse handles docId with special characters', async () => { 131 + const key = await generateKey(); 132 + const docId = 'doc-with_special.chars123'; 133 + const hash = await buildHash(docId, key); 134 + const raw = hash.slice(1); 135 + const slash = raw.indexOf('/'); 136 + expect(raw.slice(0, slash)).toBe(docId); 137 + }); 138 + 139 + it('hash with extra slash segments still extracts first docId and rest as key', async () => { 140 + const key = await generateKey(); 141 + const keyStr = await exportKey(key); 142 + // Simulate a hash with extra fragments: #docId/keyStr/extra/stuff 143 + const hash = `#myDoc/${keyStr}/extra/stuff`; 144 + const raw = hash.slice(1); 145 + const slash = raw.indexOf('/'); 146 + const parsedDocId = raw.slice(0, slash); 147 + const parsedKeyPart = raw.slice(slash + 1); 148 + expect(parsedDocId).toBe('myDoc'); 149 + // The keyString would contain the extra parts, which would fail on import 150 + // This validates that extra fragments are not silently dropped 151 + expect(parsedKeyPart).toContain('/extra/stuff'); 152 + }); 153 + }); 154 + 155 + describe('tampered and truncated ciphertext', () => { 156 + it('decrypt throws when a byte in the middle of ciphertext is flipped', async () => { 157 + const key = await generateKey(); 158 + const plaintext = new TextEncoder().encode('sensitive data here'); 159 + const ciphertext = await encrypt(plaintext, key); 160 + 161 + // Flip a byte in the ciphertext body (past the 12-byte IV) 162 + const tampered = new Uint8Array(ciphertext); 163 + const midpoint = Math.floor((12 + ciphertext.length) / 2); 164 + tampered[midpoint] ^= 0x01; 165 + 166 + await expect(decrypt(tampered, key)).rejects.toThrow(); 167 + }); 168 + 169 + it('decrypt throws when the IV is modified', async () => { 170 + const key = await generateKey(); 171 + const plaintext = new TextEncoder().encode('test data'); 172 + const ciphertext = await encrypt(plaintext, key); 173 + 174 + const tampered = new Uint8Array(ciphertext); 175 + tampered[0] ^= 0xff; // flip first byte of IV 176 + 177 + await expect(decrypt(tampered, key)).rejects.toThrow(); 178 + }); 179 + 180 + it('decrypt throws when ciphertext is truncated to just the IV', async () => { 181 + const key = await generateKey(); 182 + const plaintext = new TextEncoder().encode('some content'); 183 + const ciphertext = await encrypt(plaintext, key); 184 + 185 + // Truncate to just the 12-byte IV (no ciphertext body at all) 186 + const truncated = ciphertext.slice(0, 12); 187 + await expect(decrypt(truncated, key)).rejects.toThrow(); 188 + }); 189 + 190 + it('decrypt throws when ciphertext is truncated mid-body', async () => { 191 + const key = await generateKey(); 192 + const plaintext = new TextEncoder().encode('important message'); 193 + const ciphertext = await encrypt(plaintext, key); 194 + 195 + // Cut off the last half of the ciphertext (removes auth tag) 196 + const half = Math.floor(ciphertext.length / 2); 197 + const truncated = ciphertext.slice(0, half); 198 + await expect(decrypt(truncated, key)).rejects.toThrow(); 199 + }); 200 + 201 + it('decrypt throws with completely empty data', async () => { 202 + const key = await generateKey(); 203 + await expect(decrypt(new Uint8Array(0), key)).rejects.toThrow(); 204 + }); 205 + }); 206 + 207 + describe('IV randomness (encrypt output differs each call)', () => { 208 + it('same plaintext encrypted 10 times produces 10 unique ciphertexts', async () => { 209 + const key = await generateKey(); 210 + const plaintext = new Uint8Array([1, 2, 3, 4, 5]); 211 + 212 + const ciphertexts = new Set<string>(); 213 + for (let i = 0; i < 10; i++) { 214 + const ct = await encrypt(plaintext, key); 215 + ciphertexts.add(Array.from(ct).join(',')); 216 + } 217 + // All 10 should be unique (different IVs) 218 + expect(ciphertexts.size).toBe(10); 219 + }); 220 + 221 + it('the first 12 bytes (IV) differ between encryptions', async () => { 222 + const key = await generateKey(); 223 + const plaintext = new Uint8Array([42]); 224 + 225 + const ct1 = await encrypt(plaintext, key); 226 + const ct2 = await encrypt(plaintext, key); 227 + 228 + const iv1 = Array.from(ct1.slice(0, 12)); 229 + const iv2 = Array.from(ct2.slice(0, 12)); 230 + expect(iv1).not.toEqual(iv2); 231 + }); 232 + });
+249
tests/diagram-action-security.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createWhiteboard, addShape, addArrow, removeShape, moveShape, resizeShape, 4 + setShapeLabel, setShapeStyle, snapPoint, 5 + type WhiteboardState, 6 + } from '../src/diagrams/whiteboard.js'; 7 + 8 + /** 9 + * Security-focused whiteboard/diagram tests. 10 + * Tests HTML injection in labels, extreme coordinate values, 11 + * boundary conditions, and sanitization at the data model layer. 12 + */ 13 + 14 + describe('shape label with HTML/script tags', () => { 15 + it('stores HTML script tag in label without crashing', () => { 16 + let state = createWhiteboard(); 17 + state = addShape(state, 'rectangle', 100, 100, 120, 80, '<script>alert("xss")</script>'); 18 + const shapes = Array.from(state.shapes.values()); 19 + expect(shapes.length).toBe(1); 20 + expect(shapes[0]!.label).toBe('<script>alert("xss")</script>'); 21 + }); 22 + 23 + it('stores img onerror tag in label without crashing', () => { 24 + let state = createWhiteboard(); 25 + state = addShape(state, 'ellipse', 0, 0, 50, 50, '<img src=x onerror=alert(1)>'); 26 + const shapes = Array.from(state.shapes.values()); 27 + expect(shapes[0]!.label).toBe('<img src=x onerror=alert(1)>'); 28 + }); 29 + 30 + it('handles SVG injection attempt in label', () => { 31 + let state = createWhiteboard(); 32 + state = addShape(state, 'rectangle', 0, 0, 100, 100, '<svg onload=alert(1)>'); 33 + const shapes = Array.from(state.shapes.values()); 34 + expect(shapes[0]!.label).toBe('<svg onload=alert(1)>'); 35 + }); 36 + 37 + it('setShapeLabel with HTML content works', () => { 38 + let state = createWhiteboard(); 39 + state = addShape(state, 'rectangle', 0, 0, 100, 100, 'clean'); 40 + const id = Array.from(state.shapes.keys())[0]!; 41 + state = setShapeLabel(state, id, '<iframe src="javascript:alert(1)"></iframe>'); 42 + expect(state.shapes.get(id)!.label).toBe('<iframe src="javascript:alert(1)"></iframe>'); 43 + }); 44 + }); 45 + 46 + describe('very long label strings', () => { 47 + it('handles 10K character label without crashing', () => { 48 + let state = createWhiteboard(); 49 + const longLabel = 'A'.repeat(10_000); 50 + state = addShape(state, 'rectangle', 50, 50, 200, 100, longLabel); 51 + const shapes = Array.from(state.shapes.values()); 52 + expect(shapes[0]!.label).toBe(longLabel); 53 + expect(shapes[0]!.label.length).toBe(10_000); 54 + }); 55 + 56 + it('handles unicode-heavy long label', () => { 57 + let state = createWhiteboard(); 58 + const label = '\u4F60\u597D\u4E16\u754C'.repeat(2500); // ~10K chars of CJK 59 + state = addShape(state, 'rectangle', 0, 0, 100, 100, label); 60 + expect(Array.from(state.shapes.values())[0]!.label).toBe(label); 61 + }); 62 + }); 63 + 64 + describe('extreme coordinate values', () => { 65 + it('handles Number.MAX_SAFE_INTEGER coordinates', () => { 66 + // Disable snap-to-grid to avoid Math.round overflow issues 67 + let state = createWhiteboard(); 68 + state = { ...state, snapToGrid: false }; 69 + state = addShape(state, 'rectangle', Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, 100, 100); 70 + const shapes = Array.from(state.shapes.values()); 71 + expect(shapes.length).toBe(1); 72 + expect(shapes[0]!.x).toBe(Number.MAX_SAFE_INTEGER); 73 + }); 74 + 75 + it('handles negative coordinates (valid off-canvas placement)', () => { 76 + let state = createWhiteboard(); 77 + state = { ...state, snapToGrid: false }; 78 + state = addShape(state, 'diamond', -500, -300, 80, 60); 79 + const shapes = Array.from(state.shapes.values()); 80 + expect(shapes[0]!.x).toBe(-500); 81 + expect(shapes[0]!.y).toBe(-300); 82 + }); 83 + 84 + it('handles very large negative coordinates', () => { 85 + let state = createWhiteboard(); 86 + state = { ...state, snapToGrid: false }; 87 + state = addShape(state, 'rectangle', -Number.MAX_SAFE_INTEGER, -Number.MAX_SAFE_INTEGER, 50, 50); 88 + const shapes = Array.from(state.shapes.values()); 89 + expect(shapes.length).toBe(1); 90 + }); 91 + 92 + it('moveShape with extreme coordinates does not crash', () => { 93 + let state = createWhiteboard(); 94 + state = { ...state, snapToGrid: false }; 95 + state = addShape(state, 'rectangle', 0, 0, 100, 100); 96 + const id = Array.from(state.shapes.keys())[0]!; 97 + state = moveShape(state, id, Number.MAX_SAFE_INTEGER, -Number.MAX_SAFE_INTEGER); 98 + const shape = state.shapes.get(id)!; 99 + expect(shape.x).toBe(Number.MAX_SAFE_INTEGER); 100 + expect(shape.y).toBe(-Number.MAX_SAFE_INTEGER); 101 + }); 102 + }); 103 + 104 + describe('zero-size shapes', () => { 105 + it('addShape with w=0 and h=0 does not crash', () => { 106 + let state = createWhiteboard(); 107 + state = addShape(state, 'rectangle', 100, 100, 0, 0); 108 + const shapes = Array.from(state.shapes.values()); 109 + expect(shapes.length).toBe(1); 110 + expect(shapes[0]!.width).toBe(0); 111 + expect(shapes[0]!.height).toBe(0); 112 + }); 113 + 114 + it('resizeShape clamps to minimum of 10', () => { 115 + let state = createWhiteboard(); 116 + state = addShape(state, 'rectangle', 0, 0, 100, 100); 117 + const id = Array.from(state.shapes.keys())[0]!; 118 + state = resizeShape(state, id, 0, 0); 119 + const shape = state.shapes.get(id)!; 120 + // resizeShape uses Math.max(10, ...) so it clamps 121 + expect(shape.width).toBe(10); 122 + expect(shape.height).toBe(10); 123 + }); 124 + 125 + it('resizeShape with negative dimensions clamps to 10', () => { 126 + let state = createWhiteboard(); 127 + state = addShape(state, 'rectangle', 0, 0, 100, 100); 128 + const id = Array.from(state.shapes.keys())[0]!; 129 + state = resizeShape(state, id, -50, -50); 130 + const shape = state.shapes.get(id)!; 131 + expect(shape.width).toBe(10); 132 + expect(shape.height).toBe(10); 133 + }); 134 + }); 135 + 136 + describe('NaN coordinate behavior', () => { 137 + it('addShape with NaN coordinates does not crash', () => { 138 + let state = createWhiteboard(); 139 + state = { ...state, snapToGrid: false }; 140 + state = addShape(state, 'rectangle', NaN, NaN, 100, 100); 141 + const shapes = Array.from(state.shapes.values()); 142 + expect(shapes.length).toBe(1); 143 + }); 144 + 145 + it('addShape with NaN dimensions does not crash', () => { 146 + let state = createWhiteboard(); 147 + state = addShape(state, 'rectangle', 100, 100, NaN, NaN); 148 + const shapes = Array.from(state.shapes.values()); 149 + expect(shapes.length).toBe(1); 150 + }); 151 + 152 + it('Infinity coordinates do not crash', () => { 153 + let state = createWhiteboard(); 154 + state = { ...state, snapToGrid: false }; 155 + state = addShape(state, 'rectangle', Infinity, -Infinity, 100, 100); 156 + const shapes = Array.from(state.shapes.values()); 157 + expect(shapes.length).toBe(1); 158 + }); 159 + }); 160 + 161 + describe('self-referencing arrow', () => { 162 + it('creates arrow when fromId and toId reference the same shape', () => { 163 + let state = createWhiteboard(); 164 + state = addShape(state, 'rectangle', 100, 100, 120, 80, 'Node A'); 165 + const shapeId = Array.from(state.shapes.keys())[0]!; 166 + 167 + state = addArrow( 168 + state, 169 + { shapeId, anchor: 'right' }, 170 + { shapeId, anchor: 'left' }, 171 + ); 172 + expect(state.arrows.size).toBe(1); 173 + const arrow = Array.from(state.arrows.values())[0]!; 174 + expect('shapeId' in arrow.from && arrow.from.shapeId).toBe(shapeId); 175 + expect('shapeId' in arrow.to && arrow.to.shapeId).toBe(shapeId); 176 + }); 177 + }); 178 + 179 + describe('style injection via setShapeStyle', () => { 180 + it('setShapeStyle stores arbitrary style values (sanitization is renderer responsibility)', () => { 181 + let state = createWhiteboard(); 182 + state = addShape(state, 'rectangle', 0, 0, 100, 100); 183 + const id = Array.from(state.shapes.keys())[0]!; 184 + // setShapeStyle is a raw setter — it stores whatever is given 185 + state = setShapeStyle(state, [id], { fill: 'url(javascript:alert(1))' }); 186 + expect(state.shapes.get(id)!.style.fill).toBe('url(javascript:alert(1))'); 187 + // Note: this is the correct behavior for the data model layer. 188 + // The AI action executor layer (ai-diagram-actions.ts) adds sanitizeColor 189 + // on top of this to reject non-hex colors before they reach setShapeStyle. 190 + }); 191 + }); 192 + 193 + describe('operations on non-existent shapes', () => { 194 + it('moveShape with invalid ID returns state unchanged', () => { 195 + let state = createWhiteboard(); 196 + state = addShape(state, 'rectangle', 0, 0, 100, 100); 197 + const before = state; 198 + state = moveShape(state, 'nonexistent-id', 500, 500); 199 + // Should return the same state (no mutation, no crash) 200 + expect(state.shapes.size).toBe(before.shapes.size); 201 + }); 202 + 203 + it('resizeShape with invalid ID returns state unchanged', () => { 204 + const state = createWhiteboard(); 205 + const result = resizeShape(state, 'nonexistent', 200, 200); 206 + expect(result).toBe(state); 207 + }); 208 + 209 + it('setShapeLabel with invalid ID returns state unchanged', () => { 210 + const state = createWhiteboard(); 211 + const result = setShapeLabel(state, 'nonexistent', 'new label'); 212 + expect(result).toBe(state); 213 + }); 214 + 215 + it('removeShape with invalid ID still returns valid state', () => { 216 + let state = createWhiteboard(); 217 + state = addShape(state, 'rectangle', 0, 0, 100, 100); 218 + const result = removeShape(state, 'nonexistent'); 219 + expect(result.shapes.size).toBe(1); 220 + }); 221 + }); 222 + 223 + describe('snapPoint edge cases', () => { 224 + it('snaps NaN to NaN without crashing', () => { 225 + const result = snapPoint(NaN, NaN, 20); 226 + expect(isNaN(result.x)).toBe(true); 227 + expect(isNaN(result.y)).toBe(true); 228 + }); 229 + 230 + it('snaps with gridSize of 0 (division by zero)', () => { 231 + // Math.round(x / 0) * 0 = NaN * 0 = NaN for non-zero x 232 + // Math.round(0 / 0) * 0 = NaN 233 + const result = snapPoint(100, 100, 0); 234 + // Just verify it does not throw 235 + expect(typeof result.x).toBe('number'); 236 + }); 237 + 238 + it('snaps with gridSize of 1 returns same coordinates', () => { 239 + const result = snapPoint(42, 73, 1); 240 + expect(result.x).toBe(42); 241 + expect(result.y).toBe(73); 242 + }); 243 + 244 + it('snaps negative coordinates correctly', () => { 245 + const result = snapPoint(-15, -25, 20); 246 + expect(result.x).toBe(-20); 247 + expect(result.y).toBe(-20); 248 + }); 249 + });
+286
tests/form-action-security.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createForm, addQuestion, removeQuestion, updateQuestion, 4 + addOption, createOption, validateAnswer, validateSubmission, 5 + questionCount, 6 + type FormSchema, type QuestionType, 7 + } from '../src/forms/form-builder.js'; 8 + 9 + /** 10 + * Form builder security tests — edge cases for the form data model. 11 + * Tests HTML injection in labels, extreme inputs, special characters, 12 + * and boundary conditions in question/option handling. 13 + */ 14 + 15 + describe('question label with HTML tags', () => { 16 + it('stores HTML script tag in label without crashing', () => { 17 + let form = createForm('Test'); 18 + form = addQuestion(form, 'short_text', '<script>alert("xss")</script>'); 19 + expect(form.questions.length).toBe(1); 20 + expect(form.questions[0]!.label).toBe('<script>alert("xss")</script>'); 21 + }); 22 + 23 + it('stores img onerror tag in label without crashing', () => { 24 + let form = createForm('Test'); 25 + form = addQuestion(form, 'long_text', '<img src=x onerror=alert(document.cookie)>'); 26 + expect(form.questions[0]!.label).toBe('<img src=x onerror=alert(document.cookie)>'); 27 + }); 28 + 29 + it('stores nested HTML/iframe in label', () => { 30 + let form = createForm('Test'); 31 + form = addQuestion(form, 'short_text', '<div><iframe src="javascript:alert(1)"></iframe></div>'); 32 + expect(form.questions[0]!.label).toContain('<iframe'); 33 + }); 34 + 35 + it('updateQuestion with HTML label works', () => { 36 + let form = createForm('Test'); 37 + form = addQuestion(form, 'short_text', 'clean'); 38 + const qId = form.questions[0]!.id; 39 + form = updateQuestion(form, qId, { label: '<svg onload=alert(1)>' }); 40 + expect(form.questions[0]!.label).toBe('<svg onload=alert(1)>'); 41 + }); 42 + }); 43 + 44 + describe('very long question labels', () => { 45 + it('handles 5K character label without crashing', () => { 46 + let form = createForm('Test'); 47 + const longLabel = 'Q'.repeat(5000); 48 + form = addQuestion(form, 'short_text', longLabel); 49 + expect(form.questions[0]!.label).toBe(longLabel); 50 + expect(form.questions[0]!.label.length).toBe(5000); 51 + }); 52 + 53 + it('handles unicode-heavy long label', () => { 54 + let form = createForm('Test'); 55 + const label = '\u4F60\u597D\u4E16\u754C'.repeat(1250); // ~5K chars 56 + form = addQuestion(form, 'short_text', label); 57 + expect(form.questions[0]!.label).toBe(label); 58 + }); 59 + }); 60 + 61 + describe('options array edge cases', () => { 62 + it('handles 100+ options without crashing', () => { 63 + let form = createForm('Test'); 64 + const options = Array.from({ length: 150 }, (_, i) => createOption(`Option ${i + 1}`)); 65 + form = addQuestion(form, 'single_choice', 'Many Options', { options }); 66 + expect(form.questions[0]!.options.length).toBe(150); 67 + }); 68 + 69 + it('handles empty options array', () => { 70 + let form = createForm('Test'); 71 + form = addQuestion(form, 'dropdown', 'No Options', { options: [] }); 72 + expect(form.questions[0]!.options.length).toBe(0); 73 + }); 74 + 75 + it('handles omitted options (default)', () => { 76 + let form = createForm('Test'); 77 + form = addQuestion(form, 'short_text', 'No Options'); 78 + expect(form.questions[0]!.options.length).toBe(0); 79 + }); 80 + 81 + it('addOption to a question that already has options', () => { 82 + let form = createForm('Test'); 83 + form = addQuestion(form, 'single_choice', 'Colors', { 84 + options: [createOption('Red')], 85 + }); 86 + const qId = form.questions[0]!.id; 87 + form = addOption(form, qId, 'Blue'); 88 + expect(form.questions[0]!.options.length).toBe(2); 89 + expect(form.questions[0]!.options[1]!.label).toBe('Blue'); 90 + }); 91 + }); 92 + 93 + describe('option labels with special characters', () => { 94 + it('handles quotes in option labels', () => { 95 + let form = createForm('Test'); 96 + const options = [ 97 + createOption('"double"'), 98 + createOption("'single'"), 99 + createOption('`backtick`'), 100 + ]; 101 + form = addQuestion(form, 'single_choice', 'Quotes', { options }); 102 + expect(form.questions[0]!.options[0]!.label).toBe('"double"'); 103 + expect(form.questions[0]!.options[1]!.label).toBe("'single'"); 104 + expect(form.questions[0]!.options[2]!.label).toBe('`backtick`'); 105 + }); 106 + 107 + it('handles backslashes in option labels', () => { 108 + let form = createForm('Test'); 109 + const options = [ 110 + createOption('path\\to\\file'), 111 + createOption('C:\\Windows\\System32'), 112 + ]; 113 + form = addQuestion(form, 'single_choice', 'Paths', { options }); 114 + expect(form.questions[0]!.options[0]!.label).toBe('path\\to\\file'); 115 + }); 116 + 117 + it('handles null bytes in option labels', () => { 118 + let form = createForm('Test'); 119 + form = addQuestion(form, 'dropdown', 'Null Test', { 120 + options: [createOption('before\0after'), createOption('\0start')], 121 + }); 122 + expect(form.questions[0]!.options[0]!.label).toBe('before\0after'); 123 + }); 124 + 125 + it('handles unicode in option labels', () => { 126 + let form = createForm('Test'); 127 + form = addQuestion(form, 'single_choice', 'Unicode', { 128 + options: [createOption('\u4F60\u597D'), createOption('\u00E9\u00E0\u00FC')], 129 + }); 130 + expect(form.questions[0]!.options[0]!.label).toBe('\u4F60\u597D'); 131 + }); 132 + 133 + it('handles HTML in option labels', () => { 134 + let form = createForm('Test'); 135 + form = addQuestion(form, 'multiple_choice', 'HTML', { 136 + options: [ 137 + createOption('<b>bold</b>'), 138 + createOption('<script>alert(1)</script>'), 139 + createOption('<img src=x>'), 140 + ], 141 + }); 142 + expect(form.questions[0]!.options[0]!.label).toBe('<b>bold</b>'); 143 + expect(form.questions[0]!.options[1]!.label).toBe('<script>alert(1)</script>'); 144 + }); 145 + }); 146 + 147 + describe('duplicate question labels', () => { 148 + it('allows two questions with the same label', () => { 149 + let form = createForm('Test'); 150 + form = addQuestion(form, 'short_text', 'Duplicate'); 151 + form = addQuestion(form, 'long_text', 'Duplicate'); 152 + expect(form.questions.length).toBe(2); 153 + expect(form.questions[0]!.label).toBe('Duplicate'); 154 + expect(form.questions[1]!.label).toBe('Duplicate'); 155 + // Different IDs 156 + expect(form.questions[0]!.id).not.toBe(form.questions[1]!.id); 157 + }); 158 + 159 + it('different types but same label can coexist', () => { 160 + let form = createForm('Test'); 161 + form = addQuestion(form, 'short_text', 'Name'); 162 + form = addQuestion(form, 'email', 'Name'); 163 + form = addQuestion(form, 'number', 'Name'); 164 + expect(questionCount(form)).toBe(3); 165 + expect(form.questions[0]!.type).toBe('short_text'); 166 + expect(form.questions[1]!.type).toBe('email'); 167 + expect(form.questions[2]!.type).toBe('number'); 168 + }); 169 + }); 170 + 171 + describe('removeQuestion with invalid ID', () => { 172 + it('returns form unchanged for non-existent question ID', () => { 173 + let form = createForm('Test'); 174 + form = addQuestion(form, 'short_text', 'Keep me'); 175 + const result = removeQuestion(form, 'nonexistent-id'); 176 + expect(result.questions.length).toBe(1); 177 + expect(result.questions[0]!.label).toBe('Keep me'); 178 + }); 179 + }); 180 + 181 + describe('updateQuestion with invalid ID', () => { 182 + it('returns form unchanged for non-existent question ID', () => { 183 + let form = createForm('Test'); 184 + form = addQuestion(form, 'short_text', 'Original'); 185 + const result = updateQuestion(form, 'nonexistent-id', { label: 'Changed' }); 186 + expect(result.questions[0]!.label).toBe('Original'); 187 + }); 188 + }); 189 + 190 + describe('validateAnswer edge cases', () => { 191 + it('validates required field with null answer', () => { 192 + let form = createForm('Test'); 193 + form = addQuestion(form, 'short_text', 'Required', { required: true }); 194 + const q = form.questions[0]!; 195 + expect(validateAnswer(q, null)).toBe('This field is required'); 196 + }); 197 + 198 + it('validates required field with empty string', () => { 199 + let form = createForm('Test'); 200 + form = addQuestion(form, 'short_text', 'Required', { required: true }); 201 + const q = form.questions[0]!; 202 + expect(validateAnswer(q, '')).toBe('This field is required'); 203 + }); 204 + 205 + it('allows empty string for non-required field', () => { 206 + let form = createForm('Test'); 207 + form = addQuestion(form, 'short_text', 'Optional'); 208 + const q = form.questions[0]!; 209 + expect(validateAnswer(q, '')).toBeNull(); 210 + }); 211 + 212 + it('validates email format', () => { 213 + let form = createForm('Test'); 214 + form = addQuestion(form, 'email', 'Email'); 215 + const q = form.questions[0]!; 216 + expect(validateAnswer(q, 'user@example.com')).toBeNull(); 217 + expect(validateAnswer(q, 'not-an-email')).toBe('Invalid email address'); 218 + }); 219 + 220 + it('validates number type', () => { 221 + let form = createForm('Test'); 222 + form = addQuestion(form, 'number', 'Number'); 223 + const q = form.questions[0]!; 224 + expect(validateAnswer(q, '42')).toBeNull(); 225 + expect(validateAnswer(q, 'abc')).toBe('Must be a number'); 226 + }); 227 + 228 + it('validates URL format', () => { 229 + let form = createForm('Test'); 230 + form = addQuestion(form, 'url', 'URL'); 231 + const q = form.questions[0]!; 232 + expect(validateAnswer(q, 'https://example.com')).toBeNull(); 233 + expect(validateAnswer(q, 'not-a-url')).toBe('Invalid URL'); 234 + }); 235 + 236 + it('validates scale within bounds', () => { 237 + let form = createForm('Test'); 238 + form = addQuestion(form, 'scale', 'Scale', { scaleMin: 1, scaleMax: 10 }); 239 + const q = form.questions[0]!; 240 + expect(validateAnswer(q, '5')).toBeNull(); 241 + expect(validateAnswer(q, '11')).toContain('between'); 242 + expect(validateAnswer(q, '0')).toContain('between'); 243 + }); 244 + 245 + it('rejects invalid choice for single_choice', () => { 246 + let form = createForm('Test'); 247 + form = addQuestion(form, 'single_choice', 'Pick', { 248 + options: [createOption('A'), createOption('B')], 249 + }); 250 + const q = form.questions[0]!; 251 + expect(validateAnswer(q, 'A')).toBeNull(); 252 + expect(validateAnswer(q, 'C')).toBe('Invalid choice'); 253 + }); 254 + 255 + it('handles malicious regex in validationPattern gracefully', () => { 256 + let form = createForm('Test'); 257 + form = addQuestion(form, 'short_text', 'Regex', { 258 + validationPattern: '[invalid', 259 + }); 260 + const q = form.questions[0]!; 261 + // Invalid regex should be silently skipped, not crash 262 + expect(validateAnswer(q, 'anything')).toBeNull(); 263 + }); 264 + }); 265 + 266 + describe('validateSubmission', () => { 267 + it('returns errors map for missing required fields', () => { 268 + let form = createForm('Test'); 269 + form = addQuestion(form, 'short_text', 'Name', { required: true }); 270 + form = addQuestion(form, 'email', 'Email', { required: true }); 271 + const answers = new Map<string, unknown>(); 272 + const errors = validateSubmission(form, answers); 273 + expect(errors.size).toBe(2); 274 + }); 275 + 276 + it('returns empty map when all fields valid', () => { 277 + let form = createForm('Test'); 278 + form = addQuestion(form, 'short_text', 'Name'); 279 + form = addQuestion(form, 'number', 'Age'); 280 + const answers = new Map<string, unknown>(); 281 + answers.set(form.questions[0]!.id, 'John'); 282 + answers.set(form.questions[1]!.id, '30'); 283 + const errors = validateSubmission(form, answers); 284 + expect(errors.size).toBe(0); 285 + }); 286 + });
+477
tests/form-builder-integrity.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createForm, 4 + addQuestion, 5 + removeQuestion, 6 + updateQuestion, 7 + moveQuestion, 8 + addOption, 9 + createOption, 10 + validateAnswer, 11 + validateSubmission, 12 + questionCount, 13 + requiredCount, 14 + setTargetSheet, 15 + duplicateForm, 16 + type Question, 17 + type FormSchema, 18 + } from '../src/forms/form-builder.js'; 19 + 20 + // ===================================================================== 21 + // 1. CREATE FORM, ADD QUESTION, REMOVE IT — form empty again 22 + // ===================================================================== 23 + 24 + describe('Form builder integrity — add then remove question', () => { 25 + it('form is empty after adding and removing the only question', () => { 26 + let form = createForm('Test Form'); 27 + form = addQuestion(form, 'short_text', 'Name'); 28 + expect(questionCount(form)).toBe(1); 29 + 30 + const qId = form.questions[0].id; 31 + form = removeQuestion(form, qId); 32 + 33 + expect(questionCount(form)).toBe(0); 34 + expect(form.questions).toEqual([]); 35 + }); 36 + 37 + it('removing non-existent question ID does not change the form', () => { 38 + let form = createForm('Test'); 39 + form = addQuestion(form, 'short_text', 'Q1'); 40 + const beforeCount = questionCount(form); 41 + 42 + form = removeQuestion(form, 'non-existent-id'); 43 + expect(questionCount(form)).toBe(beforeCount); 44 + }); 45 + 46 + it('updatedAt changes when question is removed', () => { 47 + let form = createForm('Test'); 48 + form = addQuestion(form, 'short_text', 'Q1'); 49 + const addedAt = form.updatedAt; 50 + 51 + // Small delay to ensure timestamp difference 52 + form = removeQuestion(form, form.questions[0].id); 53 + expect(form.updatedAt).toBeGreaterThanOrEqual(addedAt); 54 + }); 55 + }); 56 + 57 + // ===================================================================== 58 + // 2. REORDER QUESTIONS — verify order preserved 59 + // ===================================================================== 60 + 61 + describe('Form builder integrity — reorder questions', () => { 62 + it('moveQuestion to beginning reorders correctly', () => { 63 + let form = createForm('Test'); 64 + form = addQuestion(form, 'short_text', 'A'); 65 + form = addQuestion(form, 'short_text', 'B'); 66 + form = addQuestion(form, 'short_text', 'C'); 67 + 68 + const cId = form.questions[2].id; 69 + form = moveQuestion(form, cId, 0); 70 + 71 + expect(form.questions.map(q => q.label)).toEqual(['C', 'A', 'B']); 72 + }); 73 + 74 + it('moveQuestion to end reorders correctly', () => { 75 + let form = createForm('Test'); 76 + form = addQuestion(form, 'short_text', 'A'); 77 + form = addQuestion(form, 'short_text', 'B'); 78 + form = addQuestion(form, 'short_text', 'C'); 79 + 80 + const aId = form.questions[0].id; 81 + form = moveQuestion(form, aId, 2); 82 + 83 + expect(form.questions.map(q => q.label)).toEqual(['B', 'C', 'A']); 84 + }); 85 + 86 + it('moveQuestion to same position does not change order', () => { 87 + let form = createForm('Test'); 88 + form = addQuestion(form, 'short_text', 'A'); 89 + form = addQuestion(form, 'short_text', 'B'); 90 + form = addQuestion(form, 'short_text', 'C'); 91 + 92 + const bId = form.questions[1].id; 93 + form = moveQuestion(form, bId, 1); 94 + 95 + expect(form.questions.map(q => q.label)).toEqual(['A', 'B', 'C']); 96 + }); 97 + 98 + it('moveQuestion with out-of-bounds index clamps', () => { 99 + let form = createForm('Test'); 100 + form = addQuestion(form, 'short_text', 'A'); 101 + form = addQuestion(form, 'short_text', 'B'); 102 + 103 + const aId = form.questions[0].id; 104 + form = moveQuestion(form, aId, 999); 105 + 106 + // Should move A to the end 107 + expect(form.questions[1].label).toBe('A'); 108 + }); 109 + 110 + it('moveQuestion with negative index clamps to 0', () => { 111 + let form = createForm('Test'); 112 + form = addQuestion(form, 'short_text', 'A'); 113 + form = addQuestion(form, 'short_text', 'B'); 114 + 115 + const bId = form.questions[1].id; 116 + form = moveQuestion(form, bId, -5); 117 + 118 + // Should move B to the beginning 119 + expect(form.questions[0].label).toBe('B'); 120 + }); 121 + }); 122 + 123 + // ===================================================================== 124 + // 3. UPDATE QUESTION TYPE — preserves other fields 125 + // ===================================================================== 126 + 127 + describe('Form builder integrity — update question type preserves fields', () => { 128 + it('changing type preserves label, required, and description', () => { 129 + let form = createForm('Test'); 130 + form = addQuestion(form, 'short_text', 'Email Address', { 131 + required: true, 132 + description: 'Enter your email', 133 + }); 134 + const qId = form.questions[0].id; 135 + 136 + form = updateQuestion(form, qId, { type: 'email' }); 137 + 138 + const q = form.questions[0]; 139 + expect(q.type).toBe('email'); 140 + expect(q.label).toBe('Email Address'); 141 + expect(q.required).toBe(true); 142 + expect(q.description).toBe('Enter your email'); 143 + }); 144 + 145 + it('changing type preserves options on choice question', () => { 146 + let form = createForm('Test'); 147 + form = addQuestion(form, 'single_choice', 'Pick one'); 148 + const qId = form.questions[0].id; 149 + form = addOption(form, qId, 'Option A'); 150 + form = addOption(form, qId, 'Option B'); 151 + 152 + // Change type to dropdown — options should still be there 153 + form = updateQuestion(form, qId, { type: 'dropdown' }); 154 + 155 + expect(form.questions[0].type).toBe('dropdown'); 156 + expect(form.questions[0].options).toHaveLength(2); 157 + expect(form.questions[0].options[0].label).toBe('Option A'); 158 + }); 159 + 160 + it('updating label does not change type or required', () => { 161 + let form = createForm('Test'); 162 + form = addQuestion(form, 'number', 'Age', { required: true }); 163 + const qId = form.questions[0].id; 164 + 165 + form = updateQuestion(form, qId, { label: 'Your Age' }); 166 + 167 + expect(form.questions[0].label).toBe('Your Age'); 168 + expect(form.questions[0].type).toBe('number'); 169 + expect(form.questions[0].required).toBe(true); 170 + }); 171 + }); 172 + 173 + // ===================================================================== 174 + // 4. QUESTION WITH OPTIONS — modify option labels, add/remove 175 + // ===================================================================== 176 + 177 + describe('Form builder integrity — option management', () => { 178 + it('addOption appends option to question', () => { 179 + let form = createForm('Test'); 180 + form = addQuestion(form, 'single_choice', 'Color'); 181 + const qId = form.questions[0].id; 182 + 183 + form = addOption(form, qId, 'Red'); 184 + form = addOption(form, qId, 'Green'); 185 + form = addOption(form, qId, 'Blue'); 186 + 187 + expect(form.questions[0].options).toHaveLength(3); 188 + expect(form.questions[0].options.map(o => o.label)).toEqual(['Red', 'Green', 'Blue']); 189 + }); 190 + 191 + it('options have unique IDs', () => { 192 + let form = createForm('Test'); 193 + form = addQuestion(form, 'single_choice', 'Pick'); 194 + const qId = form.questions[0].id; 195 + 196 + form = addOption(form, qId, 'A'); 197 + form = addOption(form, qId, 'B'); 198 + form = addOption(form, qId, 'C'); 199 + 200 + const ids = form.questions[0].options.map(o => o.id); 201 + const uniqueIds = new Set(ids); 202 + expect(uniqueIds.size).toBe(3); 203 + }); 204 + 205 + it('updateQuestion can modify options array', () => { 206 + let form = createForm('Test'); 207 + form = addQuestion(form, 'single_choice', 'Pick'); 208 + const qId = form.questions[0].id; 209 + form = addOption(form, qId, 'A'); 210 + form = addOption(form, qId, 'B'); 211 + 212 + // Replace options entirely 213 + const newOptions = [createOption('X'), createOption('Y'), createOption('Z')]; 214 + form = updateQuestion(form, qId, { options: newOptions }); 215 + 216 + expect(form.questions[0].options).toHaveLength(3); 217 + expect(form.questions[0].options.map(o => o.label)).toEqual(['X', 'Y', 'Z']); 218 + }); 219 + 220 + it('removing question also removes its options', () => { 221 + let form = createForm('Test'); 222 + form = addQuestion(form, 'single_choice', 'Pick'); 223 + const qId = form.questions[0].id; 224 + form = addOption(form, qId, 'A'); 225 + form = addOption(form, qId, 'B'); 226 + 227 + form = removeQuestion(form, qId); 228 + 229 + expect(form.questions).toHaveLength(0); 230 + }); 231 + }); 232 + 233 + // ===================================================================== 234 + // 5. FORM WITH 50+ QUESTIONS — all accessible and ordered 235 + // ===================================================================== 236 + 237 + describe('Form builder integrity — large form (50+ questions)', () => { 238 + it('form with 50 questions preserves order', () => { 239 + let form = createForm('Large Form'); 240 + for (let i = 0; i < 50; i++) { 241 + form = addQuestion(form, 'short_text', `Question ${i}`); 242 + } 243 + 244 + expect(questionCount(form)).toBe(50); 245 + 246 + for (let i = 0; i < 50; i++) { 247 + expect(form.questions[i].label).toBe(`Question ${i}`); 248 + } 249 + }); 250 + 251 + it('all 50 questions have unique IDs', () => { 252 + let form = createForm('Large Form'); 253 + for (let i = 0; i < 50; i++) { 254 + form = addQuestion(form, 'short_text', `Q${i}`); 255 + } 256 + 257 + const ids = form.questions.map(q => q.id); 258 + const uniqueIds = new Set(ids); 259 + expect(uniqueIds.size).toBe(50); 260 + }); 261 + 262 + it('removing question from middle of 50 preserves order of remaining', () => { 263 + let form = createForm('Large Form'); 264 + for (let i = 0; i < 50; i++) { 265 + form = addQuestion(form, 'short_text', `Q${i}`); 266 + } 267 + 268 + // Remove Q25 (middle) 269 + const q25Id = form.questions[25].id; 270 + form = removeQuestion(form, q25Id); 271 + 272 + expect(questionCount(form)).toBe(49); 273 + // Q24 should still be before Q26 274 + expect(form.questions[24].label).toBe('Q24'); 275 + expect(form.questions[25].label).toBe('Q26'); 276 + }); 277 + 278 + it('validation works on form with 50 required questions', () => { 279 + let form = createForm('Big Form'); 280 + for (let i = 0; i < 50; i++) { 281 + form = addQuestion(form, 'short_text', `Q${i}`, { required: true }); 282 + } 283 + 284 + expect(requiredCount(form)).toBe(50); 285 + 286 + // Submit empty answers — all 50 should fail 287 + const emptyAnswers = new Map<string, unknown>(); 288 + const errors = validateSubmission(form, emptyAnswers); 289 + expect(errors.size).toBe(50); 290 + }); 291 + }); 292 + 293 + // ===================================================================== 294 + // 6. REQUIRED FLAG TOGGLE — does not affect other fields 295 + // ===================================================================== 296 + 297 + describe('Form builder integrity — required flag toggle', () => { 298 + it('toggling required does not change label or type', () => { 299 + let form = createForm('Test'); 300 + form = addQuestion(form, 'email', 'Email', { 301 + required: false, 302 + description: 'Your email address', 303 + }); 304 + const qId = form.questions[0].id; 305 + 306 + // Toggle to required 307 + form = updateQuestion(form, qId, { required: true }); 308 + expect(form.questions[0].required).toBe(true); 309 + expect(form.questions[0].label).toBe('Email'); 310 + expect(form.questions[0].type).toBe('email'); 311 + expect(form.questions[0].description).toBe('Your email address'); 312 + 313 + // Toggle back to not required 314 + form = updateQuestion(form, qId, { required: false }); 315 + expect(form.questions[0].required).toBe(false); 316 + expect(form.questions[0].label).toBe('Email'); 317 + expect(form.questions[0].type).toBe('email'); 318 + expect(form.questions[0].description).toBe('Your email address'); 319 + }); 320 + 321 + it('toggling required does not affect options on choice question', () => { 322 + let form = createForm('Test'); 323 + form = addQuestion(form, 'single_choice', 'Pick', { required: false }); 324 + const qId = form.questions[0].id; 325 + form = addOption(form, qId, 'A'); 326 + form = addOption(form, qId, 'B'); 327 + 328 + form = updateQuestion(form, qId, { required: true }); 329 + 330 + expect(form.questions[0].required).toBe(true); 331 + expect(form.questions[0].options).toHaveLength(2); 332 + expect(form.questions[0].options[0].label).toBe('A'); 333 + expect(form.questions[0].options[1].label).toBe('B'); 334 + }); 335 + 336 + it('requiredCount updates correctly after toggle', () => { 337 + let form = createForm('Test'); 338 + form = addQuestion(form, 'short_text', 'Q1', { required: false }); 339 + form = addQuestion(form, 'short_text', 'Q2', { required: true }); 340 + 341 + expect(requiredCount(form)).toBe(1); 342 + 343 + form = updateQuestion(form, form.questions[0].id, { required: true }); 344 + expect(requiredCount(form)).toBe(2); 345 + 346 + form = updateQuestion(form, form.questions[1].id, { required: false }); 347 + expect(requiredCount(form)).toBe(1); 348 + }); 349 + }); 350 + 351 + // ===================================================================== 352 + // 7. VALIDATION EDGE CASES 353 + // ===================================================================== 354 + 355 + describe('Form builder integrity — validation edge cases', () => { 356 + it('validating url question with invalid URL', () => { 357 + const q: Question = { 358 + id: 'q1', type: 'url', label: 'Website', description: '', 359 + required: false, options: [], 360 + }; 361 + expect(validateAnswer(q, 'not-a-url')).toBe('Invalid URL'); 362 + expect(validateAnswer(q, 'http://example.com')).toBeNull(); 363 + expect(validateAnswer(q, 'https://example.com/path')).toBeNull(); 364 + }); 365 + 366 + it('validating date question with invalid date string', () => { 367 + const q: Question = { 368 + id: 'q1', type: 'date', label: 'Birthday', description: '', 369 + required: false, options: [], 370 + }; 371 + expect(validateAnswer(q, 'not-a-date')).toBe('Invalid date'); 372 + expect(validateAnswer(q, '2026-01-15')).toBeNull(); 373 + }); 374 + 375 + it('validating scale question respects custom min/max', () => { 376 + const q: Question = { 377 + id: 'q1', type: 'scale', label: 'Score', description: '', 378 + required: false, options: [], scaleMin: 0, scaleMax: 100, 379 + }; 380 + expect(validateAnswer(q, '-1')).toBe('Must be between 0 and 100'); 381 + expect(validateAnswer(q, '101')).toBe('Must be between 0 and 100'); 382 + expect(validateAnswer(q, '50')).toBeNull(); 383 + expect(validateAnswer(q, '0')).toBeNull(); 384 + expect(validateAnswer(q, '100')).toBeNull(); 385 + }); 386 + 387 + it('validating with custom validationPattern', () => { 388 + const q: Question = { 389 + id: 'q1', type: 'short_text', label: 'Zip', description: '', 390 + required: false, options: [], validationPattern: '^\\d{5}$', 391 + }; 392 + expect(validateAnswer(q, '12345')).toBeNull(); 393 + expect(validateAnswer(q, 'abcde')).toBe('Invalid format'); 394 + expect(validateAnswer(q, '1234')).toBe('Invalid format'); 395 + }); 396 + 397 + it('invalid regex pattern in validationPattern does not crash', () => { 398 + const q: Question = { 399 + id: 'q1', type: 'short_text', label: 'Test', description: '', 400 + required: false, options: [], validationPattern: '[invalid', 401 + }; 402 + // Should not throw — the source code catches invalid regex 403 + const result = validateAnswer(q, 'anything'); 404 + expect(result).toBeNull(); 405 + }); 406 + 407 + it('required field with null value reports error', () => { 408 + const q: Question = { 409 + id: 'q1', type: 'short_text', label: 'Name', description: '', 410 + required: true, options: [], 411 + }; 412 + expect(validateAnswer(q, null)).toBe('This field is required'); 413 + }); 414 + 415 + it('required field with undefined value reports error', () => { 416 + const q: Question = { 417 + id: 'q1', type: 'short_text', label: 'Name', description: '', 418 + required: true, options: [], 419 + }; 420 + expect(validateAnswer(q, undefined)).toBe('This field is required'); 421 + }); 422 + 423 + it('multiple_choice validates by option ID or label', () => { 424 + const q: Question = { 425 + id: 'q1', type: 'single_choice', label: 'Pick', description: '', 426 + required: false, 427 + options: [{ id: 'opt-1', label: 'Alpha' }, { id: 'opt-2', label: 'Beta' }], 428 + }; 429 + expect(validateAnswer(q, 'Alpha')).toBeNull(); 430 + expect(validateAnswer(q, 'opt-1')).toBeNull(); 431 + expect(validateAnswer(q, 'Gamma')).toBe('Invalid choice'); 432 + }); 433 + }); 434 + 435 + // ===================================================================== 436 + // 8. DUPLICATE FORM 437 + // ===================================================================== 438 + 439 + describe('Form builder integrity — duplicate form', () => { 440 + it('duplicated form has different ID', () => { 441 + let form = createForm('Original'); 442 + form = addQuestion(form, 'short_text', 'Q1'); 443 + const copy = duplicateForm(form); 444 + 445 + expect(copy.id).not.toBe(form.id); 446 + }); 447 + 448 + it('duplicated form has "(Copy)" in title', () => { 449 + const form = createForm('My Survey'); 450 + const copy = duplicateForm(form); 451 + expect(copy.title).toBe('My Survey (Copy)'); 452 + }); 453 + 454 + it('duplicated form preserves questions', () => { 455 + let form = createForm('Test'); 456 + form = addQuestion(form, 'short_text', 'Q1', { required: true }); 457 + form = addQuestion(form, 'email', 'Q2', { description: 'desc' }); 458 + 459 + const copy = duplicateForm(form); 460 + expect(copy.questions).toHaveLength(2); 461 + expect(copy.questions[0].label).toBe('Q1'); 462 + expect(copy.questions[0].required).toBe(true); 463 + expect(copy.questions[1].label).toBe('Q2'); 464 + expect(copy.questions[1].description).toBe('desc'); 465 + }); 466 + 467 + it('modifying copy does not affect original', () => { 468 + let form = createForm('Test'); 469 + form = addQuestion(form, 'short_text', 'Q1'); 470 + 471 + let copy = duplicateForm(form); 472 + copy = addQuestion(copy, 'email', 'Q2'); 473 + 474 + expect(questionCount(form)).toBe(1); 475 + expect(questionCount(copy)).toBe(2); 476 + }); 477 + });
+293
tests/formulas-security.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { evaluate, extractRefs } from '../src/sheets/formulas.js'; 3 + 4 + // Helper: evaluate with a simple cell map 5 + function evalWith(formula: string, cells: Record<string, unknown> = {}) { 6 + return evaluate(formula, (ref) => cells[ref] ?? ''); 7 + } 8 + 9 + // Helper: evaluate with cross-sheet support 10 + function evalCrossSheet( 11 + formula: string, 12 + sheetsData: Record<string, Record<string, unknown>> = {}, 13 + currentSheet = 'Sheet1', 14 + ) { 15 + const resolver = { 16 + getSheetCellValue(sheetName: string, cellRef: string) { 17 + const sheet = sheetsData[sheetName]; 18 + if (!sheet) return '#REF!'; 19 + return sheet[cellRef] ?? ''; 20 + }, 21 + sheetExists(name: string) { 22 + return name in sheetsData; 23 + }, 24 + }; 25 + return evaluate( 26 + formula, 27 + (ref) => { 28 + const sheet = sheetsData[currentSheet]; 29 + if (!sheet) return ''; 30 + return sheet[ref] ?? ''; 31 + }, 32 + resolver, 33 + ); 34 + } 35 + 36 + // ===================================================================== 37 + // 1. EXTREMELY DEEP NESTING — no stack overflow 38 + // ===================================================================== 39 + 40 + describe('Formula — extremely deep nesting', () => { 41 + it('50-level nested IF does not stack overflow', () => { 42 + // Build: IF(TRUE,IF(TRUE,IF(TRUE,...,1,...),0),0) 43 + let formula = '1'; 44 + for (let i = 0; i < 50; i++) { 45 + formula = `IF(TRUE,${formula},0)`; 46 + } 47 + const result = evalWith(formula); 48 + // Should either return 1 or #ERROR! — must NOT throw 49 + expect(result === 1 || result === '#ERROR!').toBe(true); 50 + }); 51 + 52 + it('20-level nested arithmetic does not stack overflow', () => { 53 + // Build: (((((1+1)+1)+1)+...)+1) 54 + let formula = '1'; 55 + for (let i = 0; i < 20; i++) { 56 + formula = `(${formula}+1)`; 57 + } 58 + const result = evalWith(formula); 59 + expect(result).toBe(21); 60 + }); 61 + 62 + it('deeply nested SUM/MAX/MIN evaluates correctly', () => { 63 + // SUM(MAX(MIN(SUM(MAX(MIN(1,2),3),4),5),6),7) 64 + const result = evalWith('SUM(MAX(MIN(SUM(MAX(MIN(1,2),3),4),5),6),7)'); 65 + // MIN(1,2)=1, MAX(1,3)=3, SUM(3,4)=7, MIN(7,5)=5, MAX(5,6)=6, SUM(6,7)=13 66 + expect(result).toBe(13); 67 + }); 68 + }); 69 + 70 + // ===================================================================== 71 + // 2. CIRCULAR REFERENCE VIA INDIRECT DETECTED AS ERROR 72 + // ===================================================================== 73 + 74 + describe('Formula — circular reference patterns', () => { 75 + it('INDIRECT referencing self cell evaluates to a value (detection is in recalc engine)', () => { 76 + // INDIRECT("A1") in cell A1 — the formula evaluator itself can't detect 77 + // circularity (that's the recalc engine's job), but it should not crash 78 + const result = evalWith('INDIRECT("A1")', { A1: 42 }); 79 + // INDIRECT resolves the reference, so it should return the current value 80 + expect(result === 42 || result === '' || result === '#ERROR!' || result === '#REF!').toBe(true); 81 + }); 82 + }); 83 + 84 + // ===================================================================== 85 + // 3. VERY LONG FORMULA STRING — no hang or crash 86 + // ===================================================================== 87 + 88 + describe('Formula — very long formula string', () => { 89 + it('10K character formula does not hang or crash', () => { 90 + // Build a long formula: 1+1+1+1+...+1 (about 5000 additions) 91 + const parts: string[] = []; 92 + for (let i = 0; i < 2500; i++) { 93 + parts.push('1'); 94 + } 95 + const formula = parts.join('+'); 96 + expect(formula.length).toBeGreaterThan(4000); 97 + 98 + const start = Date.now(); 99 + const result = evalWith(formula); 100 + const elapsed = Date.now() - start; 101 + 102 + // Should complete within a reasonable time (5 seconds) 103 + expect(elapsed).toBeLessThan(5000); 104 + // Result should be 2500 or #ERROR! (if the parser has a depth limit) 105 + expect(result === 2500 || result === '#ERROR!').toBe(true); 106 + }); 107 + 108 + it('formula with 1000+ character string literal does not crash', () => { 109 + const longString = 'A'.repeat(1000); 110 + const result = evalWith(`"${longString}"`); 111 + expect(result).toBe(longString); 112 + }); 113 + }); 114 + 115 + // ===================================================================== 116 + // 4. NULL BYTES IN STRING LITERALS 117 + // ===================================================================== 118 + 119 + describe('Formula — null bytes in string literals', () => { 120 + it('string with null byte is handled gracefully', () => { 121 + // The formula parser may or may not preserve null bytes 122 + const result = evalWith('"hello\\0world"'); 123 + expect(typeof result).toBe('string'); 124 + }); 125 + 126 + it('CONCATENATE with strings containing special characters', () => { 127 + const cells = { A1: 'hello\ttab', B1: 'new\nline' }; 128 + const result = evalWith('CONCATENATE(A1,B1)', cells); 129 + expect(typeof result).toBe('string'); 130 + expect(result).toContain('hello'); 131 + }); 132 + }); 133 + 134 + // ===================================================================== 135 + // 5. CONCATENATE WITH MANY ARGUMENTS 136 + // ===================================================================== 137 + 138 + describe('Formula — CONCATENATE with many arguments', () => { 139 + it('CONCATENATE with 100 arguments works correctly', () => { 140 + const cells: Record<string, unknown> = {}; 141 + const refs: string[] = []; 142 + for (let i = 1; i <= 100; i++) { 143 + cells[`A${i}`] = `x`; 144 + refs.push(`A${i}`); 145 + } 146 + const formula = `CONCATENATE(${refs.join(',')})`; 147 + const result = evalWith(formula, cells); 148 + expect(result).toBe('x'.repeat(100)); 149 + }); 150 + 151 + it('CONCATENATE with empty cells returns string of non-empty values', () => { 152 + const cells = { A1: 'hello', A2: '', A3: 'world' }; 153 + const result = evalWith('CONCATENATE(A1,A2,A3)', cells); 154 + expect(result).toBe('helloworld'); 155 + }); 156 + }); 157 + 158 + // ===================================================================== 159 + // 6. DIVISION BY ZERO IN NESTED CONTEXT — error propagation 160 + // ===================================================================== 161 + 162 + describe('Formula — division by zero error propagation', () => { 163 + it('SUM containing 1/0 propagates Infinity', () => { 164 + const result = evalWith('SUM(1/0,2)'); 165 + // 1/0 = Infinity, SUM(Infinity, 2) = Infinity 166 + expect(result).toBe(Infinity); 167 + }); 168 + 169 + it('AVERAGE with division by zero produces Infinity', () => { 170 + const cells = { A1: 1, B1: 0 }; 171 + const result = evalWith('AVERAGE(A1/B1,10)', cells); 172 + // A1/B1 = Infinity, AVERAGE(Infinity, 10) = Infinity 173 + expect(result).toBe(Infinity); 174 + }); 175 + 176 + it('IF can guard against division by zero', () => { 177 + const cells = { A1: 10, B1: 0 }; 178 + const result = evalWith('IF(B1=0,"N/A",A1/B1)', cells); 179 + expect(result).toBe('N/A'); 180 + }); 181 + 182 + it('nested 0/0 produces NaN', () => { 183 + const result = evalWith('0/0'); 184 + expect(result).toBeNaN(); 185 + }); 186 + 187 + it('IFERROR catches division by zero', () => { 188 + // IFERROR(1/0, "safe") — 1/0 is Infinity, which is not an error in JS 189 + // But IFERROR might catch it depending on implementation 190 + const result = evalWith('IFERROR(1/0,"safe")'); 191 + // Either "safe" (if Infinity is treated as error) or Infinity 192 + expect(result === 'safe' || result === Infinity).toBe(true); 193 + }); 194 + }); 195 + 196 + // ===================================================================== 197 + // 7. CROSS-SHEET REFERENCE TO NON-EXISTENT SHEET 198 + // ===================================================================== 199 + 200 + describe('Formula — cross-sheet reference to non-existent sheet', () => { 201 + it('reference to non-existent sheet returns #REF!', () => { 202 + const result = evalCrossSheet( 203 + 'NonExistentSheet!A1+1', 204 + { Sheet1: { A1: 10 } }, 205 + ); 206 + // Should return #REF! or an error — not crash 207 + expect(result === '#REF!' || result === '#ERROR!' || typeof result === 'number').toBe(true); 208 + }); 209 + 210 + it('reference to existing sheet works correctly', () => { 211 + const result = evalCrossSheet( 212 + 'Sheet2!A1+1', 213 + { Sheet1: { A1: 10 }, Sheet2: { A1: 42 } }, 214 + ); 215 + expect(result).toBe(43); 216 + }); 217 + 218 + it('SUM across non-existent sheet produces error', () => { 219 + const result = evalCrossSheet( 220 + 'SUM(FakeSheet!A1:A3)', 221 + { Sheet1: {} }, 222 + ); 223 + // Should return error or 0 (if non-existent refs are treated as empty) 224 + expect(result === '#REF!' || result === '#ERROR!' || result === 0 || typeof result === 'number').toBe(true); 225 + }); 226 + }); 227 + 228 + // ===================================================================== 229 + // 8. EXTREMELY LONG CUSTOM FORMAT STRING 230 + // ===================================================================== 231 + 232 + describe('Formula — edge cases with string operations', () => { 233 + it('TEXT function with very long format pattern does not crash', () => { 234 + // TEXT(12345, "##,##0.00") — long custom format 235 + const result = evalWith('TEXT(12345,"##,##0.00")'); 236 + // Should return a string or #ERROR!, not crash 237 + expect(typeof result === 'string' || result === '#ERROR!').toBe(true); 238 + }); 239 + 240 + it('REPT with large count does not hang', () => { 241 + // REPT("x", 10000) — repeat "x" 10000 times 242 + const result = evalWith('REPT("x",10000)'); 243 + if (typeof result === 'string') { 244 + expect(result.length).toBe(10000); 245 + } else { 246 + // If REPT doesn't exist, it might return #ERROR! 247 + expect(result).toBe('#ERROR!'); 248 + } 249 + }); 250 + 251 + it('LEFT/RIGHT/MID with out-of-bounds indices', () => { 252 + const cells = { A1: 'hello' }; 253 + // LEFT with count > string length 254 + const r1 = evalWith('LEFT(A1,100)', cells); 255 + expect(r1).toBe('hello'); 256 + 257 + // MID with start beyond string length 258 + const r2 = evalWith('MID(A1,100,5)', cells); 259 + expect(r2 === '' || typeof r2 === 'string').toBe(true); 260 + 261 + // RIGHT with count > string length 262 + const r3 = evalWith('RIGHT(A1,100)', cells); 263 + expect(r3).toBe('hello'); 264 + }); 265 + }); 266 + 267 + // ===================================================================== 268 + // 9. EDGE CASES WITH BOOLEAN AND TYPE COERCION 269 + // ===================================================================== 270 + 271 + describe('Formula — type coercion edge cases', () => { 272 + it('TRUE + 1 = 2 (boolean coerced to number)', () => { 273 + const result = evalWith('TRUE+1'); 274 + expect(result).toBe(2); 275 + }); 276 + 277 + it('FALSE + 1 = 1', () => { 278 + const result = evalWith('FALSE+1'); 279 + expect(result).toBe(1); 280 + }); 281 + 282 + it('concatenating number and boolean', () => { 283 + const result = evalWith('"Value: "&TRUE'); 284 + expect(typeof result).toBe('string'); 285 + expect(String(result)).toContain('true'); 286 + }); 287 + 288 + it('empty string in arithmetic context treated as 0', () => { 289 + const cells = { A1: '' }; 290 + const result = evalWith('A1+5', cells); 291 + expect(result).toBe(5); 292 + }); 293 + });
+527
tests/recalc-edge-cases.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { RecalcEngine, isVolatile } from '../src/sheets/recalc.js'; 3 + import { colToLetter } from '../src/sheets/formulas.js'; 4 + 5 + // --- Helpers --- 6 + 7 + function makeCellStore(data: Record<string, { v: unknown; f: string }>) { 8 + const store = new Map<string, { v: unknown; f: string }>(); 9 + for (const [id, cell] of Object.entries(data)) { 10 + store.set(id, { ...cell }); 11 + } 12 + return { 13 + get(id: string) { return store.get(id) || null; }, 14 + set(id: string, cell: { v: unknown; f: string }) { store.set(id, { ...cell }); }, 15 + has(id: string) { return store.has(id); }, 16 + entries() { return store.entries(); }, 17 + getAllFormulaCells() { 18 + const result: Array<[string, { v: unknown; f: string }]> = []; 19 + for (const [id, cell] of store.entries()) { 20 + if (cell.f) result.push([id, cell]); 21 + } 22 + return result; 23 + }, 24 + }; 25 + } 26 + 27 + // ===================================================================== 28 + // 1. DEEP DEPENDENCY CHAIN (100 cells) 29 + // ===================================================================== 30 + 31 + describe('RecalcEngine — deep dependency chain (100 cells)', () => { 32 + it('handles A1->A2->...->A100 chain without stack overflow', () => { 33 + const data: Record<string, { v: unknown; f: string }> = { 34 + A1: { v: 1, f: '' }, 35 + }; 36 + for (let i = 2; i <= 100; i++) { 37 + data[`A${i}`] = { v: '', f: `A${i - 1}+1` }; 38 + } 39 + const store = makeCellStore(data); 40 + const engine = new RecalcEngine(store); 41 + engine.buildFullGraph(); 42 + 43 + store.set('A1', { v: 0, f: '' }); 44 + engine.recalculate('A1'); 45 + 46 + // A100 should be 0 + 99 = 99 47 + expect(store.get('A100')!.v).toBe(99); 48 + }); 49 + 50 + it('evaluation order is strictly upstream-first across 100 cells', () => { 51 + const evalOrder: string[] = []; 52 + const data: Record<string, { v: unknown; f: string }> = { 53 + A1: { v: 1, f: '' }, 54 + }; 55 + for (let i = 2; i <= 100; i++) { 56 + data[`A${i}`] = { v: '', f: `A${i - 1}+1` }; 57 + } 58 + const store = makeCellStore(data); 59 + const engine = new RecalcEngine(store, { 60 + onEvaluate(cellId: string) { evalOrder.push(cellId); }, 61 + }); 62 + engine.buildFullGraph(); 63 + 64 + store.set('A1', { v: 0, f: '' }); 65 + engine.recalculate('A1'); 66 + 67 + // Every cell must be evaluated before its successor 68 + for (let i = 0; i < evalOrder.length - 1; i++) { 69 + const currentRow = parseInt(evalOrder[i].replace('A', '')); 70 + const nextRow = parseInt(evalOrder[i + 1].replace('A', '')); 71 + expect(currentRow).toBeLessThan(nextRow); 72 + } 73 + expect(evalOrder).toHaveLength(99); // A2 through A100 74 + }); 75 + 76 + it('all 99 formula cells are marked as changed after root edit', () => { 77 + const data: Record<string, { v: unknown; f: string }> = { 78 + A1: { v: 1, f: '' }, 79 + }; 80 + for (let i = 2; i <= 100; i++) { 81 + data[`A${i}`] = { v: '', f: `A${i - 1}+1` }; 82 + } 83 + const store = makeCellStore(data); 84 + const engine = new RecalcEngine(store); 85 + engine.buildFullGraph(); 86 + 87 + // Initial recalc so values are populated 88 + engine.recalculate('A1'); 89 + 90 + // Now change A1 to a different value 91 + store.set('A1', { v: 999, f: '' }); 92 + const changed = engine.recalculate('A1'); 93 + 94 + // All 99 formula cells should have changed 95 + for (let i = 2; i <= 100; i++) { 96 + expect(changed.has(`A${i}`)).toBe(true); 97 + } 98 + expect(store.get('A100')!.v).toBe(999 + 99); 99 + }); 100 + }); 101 + 102 + // ===================================================================== 103 + // 2. DIAMOND DEPENDENCY — evaluation order verification 104 + // ===================================================================== 105 + 106 + describe('RecalcEngine — diamond dependency order verification', () => { 107 + it('A1 is evaluated before D1 in diamond pattern', () => { 108 + const evalOrder: string[] = []; 109 + const store = makeCellStore({ 110 + A1: { v: 1, f: '' }, 111 + B1: { v: '', f: 'A1+10' }, 112 + C1: { v: '', f: 'A1+20' }, 113 + D1: { v: '', f: 'B1+C1' }, 114 + }); 115 + const engine = new RecalcEngine(store, { 116 + onEvaluate(cellId: string) { evalOrder.push(cellId); }, 117 + }); 118 + engine.buildFullGraph(); 119 + 120 + store.set('A1', { v: 5, f: '' }); 121 + engine.recalculate('A1'); 122 + 123 + const bIdx = evalOrder.indexOf('B1'); 124 + const cIdx = evalOrder.indexOf('C1'); 125 + const dIdx = evalOrder.indexOf('D1'); 126 + 127 + // B1 and C1 must be evaluated before D1 128 + expect(bIdx).toBeLessThan(dIdx); 129 + expect(cIdx).toBeLessThan(dIdx); 130 + 131 + // Verify correctness: B1=15, C1=25, D1=40 132 + expect(store.get('D1')!.v).toBe(40); 133 + }); 134 + 135 + it('multi-level diamond: E1 depends on C1+D1, each on A1+B1', () => { 136 + const evalOrder: string[] = []; 137 + const store = makeCellStore({ 138 + A1: { v: 1, f: '' }, 139 + B1: { v: 2, f: '' }, 140 + C1: { v: '', f: 'A1+B1' }, 141 + D1: { v: '', f: 'A1*B1' }, 142 + E1: { v: '', f: 'C1+D1' }, 143 + }); 144 + const engine = new RecalcEngine(store, { 145 + onEvaluate(cellId: string) { evalOrder.push(cellId); }, 146 + }); 147 + engine.buildFullGraph(); 148 + 149 + store.set('A1', { v: 10, f: '' }); 150 + engine.recalculate('A1'); 151 + 152 + const cIdx = evalOrder.indexOf('C1'); 153 + const dIdx = evalOrder.indexOf('D1'); 154 + const eIdx = evalOrder.indexOf('E1'); 155 + 156 + // C1 and D1 must both be evaluated before E1 157 + expect(cIdx).toBeLessThan(eIdx); 158 + expect(dIdx).toBeLessThan(eIdx); 159 + 160 + // C1=10+2=12, D1=10*2=20, E1=12+20=32 161 + expect(store.get('E1')!.v).toBe(32); 162 + }); 163 + }); 164 + 165 + // ===================================================================== 166 + // 3. SELF-REFERENCING CELL 167 + // ===================================================================== 168 + 169 + describe('RecalcEngine — self-referencing cell', () => { 170 + it('A1 = A1+1 is detected as circular', () => { 171 + const store = makeCellStore({ 172 + A1: { v: '', f: 'A1+1' }, 173 + }); 174 + const engine = new RecalcEngine(store); 175 + engine.buildFullGraph(); 176 + engine.recalculate('A1'); 177 + 178 + expect(store.get('A1')!.v).toBe('#CIRCULAR!'); 179 + }); 180 + 181 + it('self-referencing cell does not affect independent cells', () => { 182 + const store = makeCellStore({ 183 + A1: { v: '', f: 'A1+1' }, 184 + B1: { v: 5, f: '' }, 185 + C1: { v: '', f: 'B1*3' }, 186 + }); 187 + const engine = new RecalcEngine(store); 188 + engine.buildFullGraph(); 189 + 190 + engine.recalculate('A1'); 191 + expect(store.get('A1')!.v).toBe('#CIRCULAR!'); 192 + 193 + engine.recalculate('B1'); 194 + expect(store.get('C1')!.v).toBe(15); 195 + }); 196 + 197 + it('A1 = IF(A1>0, A1, 0) is still detected as circular', () => { 198 + const store = makeCellStore({ 199 + A1: { v: '', f: 'IF(A1>0,A1,0)' }, 200 + }); 201 + const engine = new RecalcEngine(store); 202 + engine.buildFullGraph(); 203 + engine.recalculate('A1'); 204 + 205 + expect(store.get('A1')!.v).toBe('#CIRCULAR!'); 206 + }); 207 + }); 208 + 209 + // ===================================================================== 210 + // 4. MULTIPLE INDEPENDENT SUBGRAPHS 211 + // ===================================================================== 212 + 213 + describe('RecalcEngine — multiple independent subgraphs', () => { 214 + it('three independent chains are recalculated correctly when all triggered', () => { 215 + const store = makeCellStore({ 216 + // Chain 1: A1 -> A2 -> A3 217 + A1: { v: 1, f: '' }, 218 + A2: { v: '', f: 'A1+1' }, 219 + A3: { v: '', f: 'A2+1' }, 220 + // Chain 2: B1 -> B2 -> B3 221 + B1: { v: 10, f: '' }, 222 + B2: { v: '', f: 'B1*2' }, 223 + B3: { v: '', f: 'B2*2' }, 224 + // Chain 3: C1 -> C2 -> C3 225 + C1: { v: 100, f: '' }, 226 + C2: { v: '', f: 'C1-1' }, 227 + C3: { v: '', f: 'C2-1' }, 228 + }); 229 + const engine = new RecalcEngine(store); 230 + engine.buildFullGraph(); 231 + 232 + // Edit all three roots 233 + store.set('A1', { v: 5, f: '' }); 234 + store.set('B1', { v: 50, f: '' }); 235 + store.set('C1', { v: 200, f: '' }); 236 + const changed = engine.recalculateMultiple(['A1', 'B1', 'C1']); 237 + 238 + expect(store.get('A3')!.v).toBe(7); // 5+1+1 239 + expect(store.get('B3')!.v).toBe(200); // 50*2*2 240 + expect(store.get('C3')!.v).toBe(198); // 200-1-1 241 + 242 + expect(changed.has('A2')).toBe(true); 243 + expect(changed.has('B2')).toBe(true); 244 + expect(changed.has('C2')).toBe(true); 245 + }); 246 + 247 + it('editing one subgraph does not trigger evaluation of another', () => { 248 + const evalOrder: string[] = []; 249 + const store = makeCellStore({ 250 + A1: { v: 1, f: '' }, 251 + A2: { v: '', f: 'A1+1' }, 252 + B1: { v: 10, f: '' }, 253 + B2: { v: '', f: 'B1+1' }, 254 + C1: { v: 100, f: '' }, 255 + C2: { v: '', f: 'C1+1' }, 256 + }); 257 + const engine = new RecalcEngine(store, { 258 + onEvaluate(cellId: string) { evalOrder.push(cellId); }, 259 + }); 260 + engine.buildFullGraph(); 261 + 262 + store.set('B1', { v: 99, f: '' }); 263 + engine.recalculate('B1'); 264 + 265 + expect(evalOrder).toContain('B2'); 266 + expect(evalOrder).not.toContain('A2'); 267 + expect(evalOrder).not.toContain('C2'); 268 + }); 269 + }); 270 + 271 + // ===================================================================== 272 + // 5. CELL REFERENCES TO NON-EXISTENT CELLS 273 + // ===================================================================== 274 + 275 + describe('RecalcEngine — non-existent cell references', () => { 276 + it('formula referencing non-existent cell evaluates gracefully (treats as empty)', () => { 277 + const store = makeCellStore({ 278 + A1: { v: '', f: 'Z99+1' }, 279 + }); 280 + const engine = new RecalcEngine(store); 281 + engine.buildFullGraph(); 282 + engine.recalculate('A1'); 283 + 284 + // Z99 is empty, so should be treated as '' or 0 in arithmetic 285 + // '' + 1 = 1 in the formula evaluator 286 + expect(store.get('A1')!.v).toBe(1); 287 + }); 288 + 289 + it('SUM including non-existent cells treats them as 0', () => { 290 + const store = makeCellStore({ 291 + A1: { v: 10, f: '' }, 292 + B1: { v: '', f: 'SUM(A1,Z1,Z2,Z3)' }, 293 + }); 294 + const engine = new RecalcEngine(store); 295 + engine.buildFullGraph(); 296 + engine.recalculate('A1'); 297 + 298 + expect(store.get('B1')!.v).toBe(10); 299 + }); 300 + 301 + it('dependency graph includes non-existent cells as precedents', () => { 302 + const store = makeCellStore({ 303 + A1: { v: '', f: 'Z99+Z100' }, 304 + }); 305 + const engine = new RecalcEngine(store); 306 + engine.buildFullGraph(); 307 + 308 + const precs = engine.getPrecedents('A1'); 309 + expect(precs.has('Z99')).toBe(true); 310 + expect(precs.has('Z100')).toBe(true); 311 + }); 312 + }); 313 + 314 + // ===================================================================== 315 + // 6. REMOVING A CELL THAT OTHERS DEPEND ON 316 + // ===================================================================== 317 + 318 + describe('RecalcEngine — removing a cell that others depend on', () => { 319 + it('dependents recalculate when a cell is cleared', () => { 320 + const store = makeCellStore({ 321 + A1: { v: 50, f: '' }, 322 + B1: { v: '', f: 'A1*2' }, 323 + C1: { v: '', f: 'B1+10' }, 324 + }); 325 + const engine = new RecalcEngine(store); 326 + engine.buildFullGraph(); 327 + 328 + // Initial recalc 329 + engine.recalculate('A1'); 330 + expect(store.get('B1')!.v).toBe(100); 331 + expect(store.get('C1')!.v).toBe(110); 332 + 333 + // "Remove" A1 by clearing it to empty 334 + store.set('A1', { v: '', f: '' }); 335 + engine.updateCell('A1'); 336 + const changed = engine.recalculate('A1'); 337 + 338 + // B1 depends on A1 which is now '' (0 in arithmetic), so B1=0 339 + expect(store.get('B1')!.v).toBe(0); 340 + // C1 depends on B1, so C1=0+10=10 341 + expect(store.get('C1')!.v).toBe(10); 342 + expect(changed.has('B1')).toBe(true); 343 + expect(changed.has('C1')).toBe(true); 344 + }); 345 + 346 + it('removing a formula cell updates the dependency graph correctly', () => { 347 + const store = makeCellStore({ 348 + A1: { v: 10, f: '' }, 349 + B1: { v: '', f: 'A1+1' }, 350 + C1: { v: '', f: 'B1+1' }, 351 + }); 352 + const engine = new RecalcEngine(store); 353 + engine.buildFullGraph(); 354 + 355 + // B1 is a formula that depends on A1 and C1 depends on B1 356 + expect(engine.getDependents('A1').has('B1')).toBe(true); 357 + expect(engine.getDependents('B1').has('C1')).toBe(true); 358 + 359 + // Clear B1's formula (make it a plain value) 360 + store.set('B1', { v: 99, f: '' }); 361 + engine.updateCell('B1'); 362 + 363 + // B1 no longer depends on A1 364 + expect(engine.getDependents('A1').has('B1')).toBe(false); 365 + // But C1 still depends on B1 366 + expect(engine.getDependents('B1').has('C1')).toBe(true); 367 + }); 368 + }); 369 + 370 + // ===================================================================== 371 + // 7. VOLATILE FUNCTIONS (NOW, RAND) — always recalculated 372 + // ===================================================================== 373 + 374 + describe('RecalcEngine — volatile functions always recalculated', () => { 375 + it('RAND() cell is always in volatile set after buildFullGraph', () => { 376 + const store = makeCellStore({ 377 + A1: { v: '', f: 'RAND()' }, 378 + B1: { v: '', f: 'A1*100' }, 379 + }); 380 + const engine = new RecalcEngine(store); 381 + engine.buildFullGraph(); 382 + 383 + expect(engine.volatileCells.has('A1')).toBe(true); 384 + expect(engine.volatileCells.has('B1')).toBe(false); 385 + }); 386 + 387 + it('NOW() and TODAY() are both volatile', () => { 388 + const store = makeCellStore({ 389 + A1: { v: '', f: 'NOW()' }, 390 + B1: { v: '', f: 'TODAY()' }, 391 + }); 392 + const engine = new RecalcEngine(store); 393 + engine.buildFullGraph(); 394 + 395 + expect(engine.volatileCells.has('A1')).toBe(true); 396 + expect(engine.volatileCells.has('B1')).toBe(true); 397 + }); 398 + 399 + it('RANDBETWEEN in a complex formula is volatile', () => { 400 + const store = makeCellStore({ 401 + A1: { v: '', f: 'IF(TRUE,RANDBETWEEN(1,100),0)' }, 402 + }); 403 + const engine = new RecalcEngine(store); 404 + engine.buildFullGraph(); 405 + 406 + expect(engine.volatileCells.has('A1')).toBe(true); 407 + }); 408 + 409 + it('volatile cell is removed from volatile set when formula cleared', () => { 410 + const store = makeCellStore({ 411 + A1: { v: '', f: 'RAND()' }, 412 + }); 413 + const engine = new RecalcEngine(store); 414 + engine.buildFullGraph(); 415 + expect(engine.volatileCells.has('A1')).toBe(true); 416 + 417 + // Clear the formula 418 + store.set('A1', { v: 42, f: '' }); 419 + engine.updateCell('A1'); 420 + 421 + expect(engine.volatileCells.has('A1')).toBe(false); 422 + }); 423 + 424 + it('recalculateVolatile triggers dependents of volatile cells', () => { 425 + const evalOrder: string[] = []; 426 + const store = makeCellStore({ 427 + A1: { v: '', f: 'RAND()' }, 428 + B1: { v: '', f: 'A1*10' }, 429 + C1: { v: '', f: 'B1+5' }, 430 + }); 431 + const engine = new RecalcEngine(store, { 432 + onEvaluate(cellId: string) { evalOrder.push(cellId); }, 433 + }); 434 + engine.buildFullGraph(); 435 + 436 + engine.recalculate('A1'); 437 + evalOrder.length = 0; 438 + 439 + engine.recalculateVolatile(); 440 + 441 + expect(evalOrder).toContain('A1'); 442 + expect(evalOrder).toContain('B1'); 443 + expect(evalOrder).toContain('C1'); 444 + }); 445 + }); 446 + 447 + // ===================================================================== 448 + // 8. SPILL RANGE COLLISION DETECTION 449 + // ===================================================================== 450 + 451 + describe('RecalcEngine — spill range collision detection', () => { 452 + it('two SEQUENCE formulas trying to spill into the same area produce #SPILL!', () => { 453 + const store = makeCellStore({ 454 + // A1 = SEQUENCE(3) will try to spill into A1, A2, A3 455 + A1: { v: '', f: 'SEQUENCE(3)' }, 456 + // A2 = SEQUENCE(3) will try to spill into A2, A3, A4 — collision with A1's spill 457 + A2: { v: '', f: 'SEQUENCE(3)' }, 458 + }); 459 + const engine = new RecalcEngine(store); 460 + engine.buildFullGraph(); 461 + engine.recalculateMultiple(['A1', 'A2']); 462 + 463 + // One of the two should get #SPILL! due to collision 464 + const a1Val = store.get('A1')!.v; 465 + const a2Val = store.get('A2')!.v; 466 + 467 + // The first-evaluated (A1 in topo order since A2 depends on nothing from A1) 468 + // will succeed; the second will hit the occupied spill target 469 + // At minimum, at least one should be #SPILL! or the collision should be detected 470 + const hasSpill = a1Val === '#SPILL!' || a2Val === '#SPILL!'; 471 + expect(hasSpill).toBe(true); 472 + }); 473 + 474 + it('SEQUENCE spill into occupied cell produces #SPILL! on source', () => { 475 + const store = makeCellStore({ 476 + // A1 = SEQUENCE(3) wants to spill into A1, A2, A3 477 + A1: { v: '', f: 'SEQUENCE(3)' }, 478 + // A2 has existing content — collision 479 + A2: { v: 'occupied', f: '' }, 480 + }); 481 + const engine = new RecalcEngine(store); 482 + engine.buildFullGraph(); 483 + engine.recalculate('A1'); 484 + 485 + expect(store.get('A1')!.v).toBe('#SPILL!'); 486 + }); 487 + 488 + it('spill succeeds when target cells are empty', () => { 489 + const store = makeCellStore({ 490 + A1: { v: '', f: 'SEQUENCE(3)' }, 491 + }); 492 + const engine = new RecalcEngine(store); 493 + engine.buildFullGraph(); 494 + engine.recalculate('A1'); 495 + 496 + // A1 should be first value (1), A2=2, A3=3 497 + expect(store.get('A1')!.v).toBe(1); 498 + expect(store.get('A2')!.v).toBe(2); 499 + expect(store.get('A3')!.v).toBe(3); 500 + }); 501 + 502 + it('getSpillRange returns correct cells for a spill source', () => { 503 + const store = makeCellStore({ 504 + A1: { v: '', f: 'SEQUENCE(3)' }, 505 + }); 506 + const engine = new RecalcEngine(store); 507 + engine.buildFullGraph(); 508 + engine.recalculate('A1'); 509 + 510 + const spillRange = engine.getSpillRange('A1'); 511 + expect(spillRange).toContain('A2'); 512 + expect(spillRange).toContain('A3'); 513 + }); 514 + 515 + it('getSpillSource returns the source for spill target cells', () => { 516 + const store = makeCellStore({ 517 + A1: { v: '', f: 'SEQUENCE(3)' }, 518 + }); 519 + const engine = new RecalcEngine(store); 520 + engine.buildFullGraph(); 521 + engine.recalculate('A1'); 522 + 523 + expect(engine.getSpillSource('A2')).toBe('A1'); 524 + expect(engine.getSpillSource('A3')).toBe('A1'); 525 + expect(engine.getSpillSource('A1')).toBeNull(); 526 + }); 527 + });
+223
tests/slide-action-security.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createDeck, addSlide, addElement, removeSlide, currentSlide, 4 + goToSlide, slideCount, elementCount, createSlide, 5 + moveElement, resizeElement, 6 + type DeckState, 7 + } from '../src/slides/canvas-engine.js'; 8 + 9 + /** 10 + * Slide canvas engine security tests — edge cases for the slide data model. 11 + * Tests HTML content in elements, extreme coordinates, zero-size elements, 12 + * and boundary conditions in slide/element management. 13 + */ 14 + 15 + describe('text content with HTML/script tags', () => { 16 + it('stores script tag in element content without crashing', () => { 17 + let state = createDeck(); 18 + state = addElement(state, 'text', 100, 100, 400, 200, '<script>alert("xss")</script>'); 19 + const slide = currentSlide(state); 20 + expect(slide.elements.length).toBe(1); 21 + expect(slide.elements[0]!.content).toBe('<script>alert("xss")</script>'); 22 + }); 23 + 24 + it('stores img onerror in content without crashing', () => { 25 + let state = createDeck(); 26 + state = addElement(state, 'text', 0, 0, 300, 100, '<img src=x onerror=alert(document.cookie)>'); 27 + const slide = currentSlide(state); 28 + expect(slide.elements[0]!.content).toBe('<img src=x onerror=alert(document.cookie)>'); 29 + }); 30 + 31 + it('handles very long text (10K chars) without crashing', () => { 32 + let state = createDeck(); 33 + const longText = 'W'.repeat(10_000); 34 + state = addElement(state, 'text', 10, 10, 800, 500, longText); 35 + const slide = currentSlide(state); 36 + expect(slide.elements[0]!.content.length).toBe(10_000); 37 + }); 38 + 39 + it('handles SVG/iframe injection in content', () => { 40 + let state = createDeck(); 41 + state = addElement(state, 'text', 0, 0, 200, 50, '<iframe src="javascript:alert(1)"></iframe>'); 42 + const slide = currentSlide(state); 43 + expect(slide.elements[0]!.content).toContain('<iframe'); 44 + }); 45 + }); 46 + 47 + describe('very large slide coordinates', () => { 48 + it('handles Number.MAX_SAFE_INTEGER coordinates', () => { 49 + let state = createDeck(); 50 + state = addElement(state, 'text', Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, 100, 50, 'far away'); 51 + const slide = currentSlide(state); 52 + expect(slide.elements[0]!.x).toBe(Number.MAX_SAFE_INTEGER); 53 + expect(slide.elements[0]!.y).toBe(Number.MAX_SAFE_INTEGER); 54 + }); 55 + 56 + it('handles negative coordinates', () => { 57 + let state = createDeck(); 58 + state = addElement(state, 'shape', -500, -300, 100, 100); 59 + const slide = currentSlide(state); 60 + expect(slide.elements[0]!.x).toBe(-500); 61 + expect(slide.elements[0]!.y).toBe(-300); 62 + }); 63 + 64 + it('handles Infinity coordinates without crashing', () => { 65 + let state = createDeck(); 66 + state = addElement(state, 'text', Infinity, -Infinity, 100, 50, 'infinity'); 67 + const slide = currentSlide(state); 68 + expect(slide.elements[0]!.x).toBe(Infinity); 69 + expect(slide.elements[0]!.y).toBe(-Infinity); 70 + }); 71 + 72 + it('handles NaN coordinates without crashing', () => { 73 + let state = createDeck(); 74 + state = addElement(state, 'text', NaN, NaN, 100, 50, 'nan'); 75 + const slide = currentSlide(state); 76 + expect(isNaN(slide.elements[0]!.x)).toBe(true); 77 + }); 78 + }); 79 + 80 + describe('zero-size elements', () => { 81 + it('handles zero width and height text element', () => { 82 + let state = createDeck(); 83 + state = addElement(state, 'text', 100, 100, 0, 0, 'zero size'); 84 + const slide = currentSlide(state); 85 + expect(slide.elements[0]!.width).toBe(0); 86 + expect(slide.elements[0]!.height).toBe(0); 87 + }); 88 + 89 + it('handles zero width and height shape element', () => { 90 + let state = createDeck(); 91 + state = addElement(state, 'shape', 50, 50, 0, 0); 92 + const slide = currentSlide(state); 93 + expect(slide.elements.length).toBe(1); 94 + }); 95 + 96 + it('resizeElement clamps to minimum of 10', () => { 97 + let state = createDeck(); 98 + state = addElement(state, 'text', 0, 0, 200, 100, 'resize me'); 99 + const elId = currentSlide(state).elements[0]!.id; 100 + state = resizeElement(state, elId, 0, 0); 101 + const el = currentSlide(state).elements[0]!; 102 + expect(el.width).toBe(10); 103 + expect(el.height).toBe(10); 104 + }); 105 + }); 106 + 107 + describe('style properties on elements', () => { 108 + it('stores arbitrary style values in elements', () => { 109 + let state = createDeck(); 110 + const style = { fontSize: '48px', color: '#ff0000' }; 111 + state = addElement(state, 'text', 0, 0, 200, 50, 'styled', style); 112 + const slide = currentSlide(state); 113 + expect(slide.elements[0]!.style.fontSize).toBe('48px'); 114 + expect(slide.elements[0]!.style.color).toBe('#ff0000'); 115 + }); 116 + 117 + it('stores empty style object', () => { 118 + let state = createDeck(); 119 + state = addElement(state, 'text', 0, 0, 200, 50, 'no style'); 120 + const slide = currentSlide(state); 121 + expect(Object.keys(slide.elements[0]!.style).length).toBe(0); 122 + }); 123 + }); 124 + 125 + describe('slide management edge cases', () => { 126 + it('removeSlide on single-slide deck returns state unchanged', () => { 127 + let state = createDeck(); 128 + expect(slideCount(state)).toBe(1); 129 + state = removeSlide(state, 0); 130 + // Cannot remove last slide 131 + expect(slideCount(state)).toBe(1); 132 + }); 133 + 134 + it('goToSlide clamps to valid range', () => { 135 + let state = createDeck(); 136 + state = addSlide(state); 137 + expect(slideCount(state)).toBe(2); 138 + 139 + state = goToSlide(state, 100); 140 + expect(state.currentSlide).toBe(1); // clamped to last 141 + 142 + state = goToSlide(state, -5); 143 + expect(state.currentSlide).toBe(0); // clamped to first 144 + }); 145 + 146 + it('addElement on a deck with multiple slides targets current slide only', () => { 147 + let state = createDeck(); 148 + state = addSlide(state); // Now 2 slides 149 + state = goToSlide(state, 1); 150 + state = addElement(state, 'text', 0, 0, 100, 50, 'on slide 2'); 151 + // Slide 0 should have no elements 152 + expect(state.slides[0]!.elements.length).toBe(0); 153 + // Slide 1 (current) should have 1 element 154 + expect(state.slides[1]!.elements.length).toBe(1); 155 + }); 156 + }); 157 + 158 + describe('multiple elements on same slide', () => { 159 + it('adds multiple elements with incrementing zIndex', () => { 160 + let state = createDeck(); 161 + state = addElement(state, 'text', 0, 0, 100, 50, 'first'); 162 + state = addElement(state, 'text', 100, 100, 100, 50, 'second'); 163 + state = addElement(state, 'shape', 200, 200, 100, 100); 164 + const slide = currentSlide(state); 165 + expect(slide.elements.length).toBe(3); 166 + expect(slide.elements[0]!.zIndex).toBe(0); 167 + expect(slide.elements[1]!.zIndex).toBe(1); 168 + expect(slide.elements[2]!.zIndex).toBe(2); 169 + }); 170 + }); 171 + 172 + describe('element type variety', () => { 173 + it('supports all element types without crashing', () => { 174 + let state = createDeck(); 175 + const types = ['text', 'image', 'shape', 'code', 'chart', 'embed'] as const; 176 + for (const type of types) { 177 + state = addElement(state, type, 0, 0, 100, 100, `content-${type}`); 178 + } 179 + const slide = currentSlide(state); 180 + expect(slide.elements.length).toBe(6); 181 + for (let i = 0; i < types.length; i++) { 182 + expect(slide.elements[i]!.type).toBe(types[i]); 183 + } 184 + }); 185 + }); 186 + 187 + describe('slide background', () => { 188 + it('createSlide with custom background', () => { 189 + const slide = createSlide('#000000'); 190 + expect(slide.background).toBe('#000000'); 191 + }); 192 + 193 + it('createSlide defaults to white background', () => { 194 + const slide = createSlide(); 195 + expect(slide.background).toBe('#ffffff'); 196 + }); 197 + 198 + it('addSlide with custom background', () => { 199 + let state = createDeck(); 200 + state = addSlide(state, undefined, '#ff0000'); 201 + expect(state.slides[1]!.background).toBe('#ff0000'); 202 + }); 203 + }); 204 + 205 + describe('moveElement edge cases', () => { 206 + it('moveElement to extreme coordinates', () => { 207 + let state = createDeck(); 208 + state = addElement(state, 'text', 0, 0, 100, 50, 'movable'); 209 + const elId = currentSlide(state).elements[0]!.id; 210 + state = moveElement(state, elId, Number.MAX_SAFE_INTEGER, -Number.MAX_SAFE_INTEGER); 211 + const el = currentSlide(state).elements[0]!; 212 + expect(el.x).toBe(Number.MAX_SAFE_INTEGER); 213 + expect(el.y).toBe(-Number.MAX_SAFE_INTEGER); 214 + }); 215 + 216 + it('moveElement with nonexistent ID does not crash', () => { 217 + let state = createDeck(); 218 + state = addElement(state, 'text', 0, 0, 100, 50, 'stays'); 219 + state = moveElement(state, 'nonexistent', 500, 500); 220 + // Element should be unchanged 221 + expect(currentSlide(state).elements[0]!.x).toBe(0); 222 + }); 223 + });
+433
tests/whiteboard-integrity.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createWhiteboard, 4 + addShape, 5 + removeShape, 6 + moveShape, 7 + resizeShape, 8 + setShapeLabel, 9 + addArrow, 10 + removeArrow, 11 + arrowsForShape, 12 + elementCounts, 13 + setShapeStyle, 14 + toggleSnap, 15 + type WhiteboardState, 16 + } from '../src/diagrams/whiteboard.js'; 17 + 18 + // Helper: create a whiteboard with snap disabled 19 + function noSnapWhiteboard(): WhiteboardState { 20 + return toggleSnap(createWhiteboard()); 21 + } 22 + 23 + // ===================================================================== 24 + // 1. ADD 100 SHAPES — all have unique IDs 25 + // ===================================================================== 26 + 27 + describe('Whiteboard integrity — 100 shapes with unique IDs', () => { 28 + it('adding 100 shapes results in 100 unique IDs', () => { 29 + let wb = noSnapWhiteboard(); 30 + for (let i = 0; i < 100; i++) { 31 + wb = addShape(wb, 'rectangle', i * 10, i * 10); 32 + } 33 + expect(wb.shapes.size).toBe(100); 34 + 35 + const ids = [...wb.shapes.keys()]; 36 + const uniqueIds = new Set(ids); 37 + expect(uniqueIds.size).toBe(100); 38 + }); 39 + 40 + it('all 100 shapes have correct kind and position', () => { 41 + let wb = noSnapWhiteboard(); 42 + for (let i = 0; i < 100; i++) { 43 + wb = addShape(wb, 'ellipse', i * 5, i * 3, 50, 30); 44 + } 45 + const shapes = [...wb.shapes.values()]; 46 + expect(shapes).toHaveLength(100); 47 + 48 + for (let i = 0; i < 100; i++) { 49 + expect(shapes[i].kind).toBe('ellipse'); 50 + expect(shapes[i].x).toBe(i * 5); 51 + expect(shapes[i].y).toBe(i * 3); 52 + } 53 + }); 54 + }); 55 + 56 + // ===================================================================== 57 + // 2. REMOVE SHAPE WITH ARROWS — arrows also removed 58 + // ===================================================================== 59 + 60 + describe('Whiteboard integrity — remove shape removes attached arrows', () => { 61 + it('removing a shape removes arrows connected FROM it', () => { 62 + let wb = noSnapWhiteboard(); 63 + wb = addShape(wb, 'rectangle', 0, 0); 64 + wb = addShape(wb, 'rectangle', 200, 0); 65 + const [id1, id2] = [...wb.shapes.keys()]; 66 + 67 + wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 68 + expect(wb.arrows.size).toBe(1); 69 + 70 + wb = removeShape(wb, id1); 71 + expect(wb.arrows.size).toBe(0); 72 + expect(wb.shapes.size).toBe(1); 73 + }); 74 + 75 + it('removing a shape removes arrows connected TO it', () => { 76 + let wb = noSnapWhiteboard(); 77 + wb = addShape(wb, 'rectangle', 0, 0); 78 + wb = addShape(wb, 'rectangle', 200, 0); 79 + const [id1, id2] = [...wb.shapes.keys()]; 80 + 81 + wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 82 + wb = removeShape(wb, id2); 83 + 84 + expect(wb.arrows.size).toBe(0); 85 + }); 86 + 87 + it('removing a shape with multiple arrows removes all of them', () => { 88 + let wb = noSnapWhiteboard(); 89 + wb = addShape(wb, 'rectangle', 0, 0); 90 + wb = addShape(wb, 'rectangle', 200, 0); 91 + wb = addShape(wb, 'rectangle', 400, 0); 92 + const [id1, id2, id3] = [...wb.shapes.keys()]; 93 + 94 + // id2 connects to both id1 and id3 95 + wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 96 + wb = addArrow(wb, { shapeId: id2, anchor: 'right' }, { shapeId: id3, anchor: 'left' }); 97 + expect(wb.arrows.size).toBe(2); 98 + 99 + wb = removeShape(wb, id2); 100 + expect(wb.arrows.size).toBe(0); 101 + expect(wb.shapes.size).toBe(2); 102 + }); 103 + 104 + it('arrows to free points are NOT removed when a shape is deleted', () => { 105 + let wb = noSnapWhiteboard(); 106 + wb = addShape(wb, 'rectangle', 0, 0); 107 + wb = addShape(wb, 'rectangle', 200, 0); 108 + const [id1, id2] = [...wb.shapes.keys()]; 109 + 110 + // Arrow from shape to free point 111 + wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { x: 500, y: 500 }); 112 + // Arrow between shapes 113 + wb = addArrow(wb, { shapeId: id1, anchor: 'bottom' }, { shapeId: id2, anchor: 'top' }); 114 + 115 + wb = removeShape(wb, id1); 116 + // Both arrows connected to id1 should be removed 117 + expect(wb.arrows.size).toBe(0); 118 + }); 119 + }); 120 + 121 + // ===================================================================== 122 + // 3. MOVE SHAPE PRESERVES ALL OTHER PROPERTIES 123 + // ===================================================================== 124 + 125 + describe('Whiteboard integrity — move preserves properties', () => { 126 + it('moveShape preserves kind, width, height, rotation, label, style, opacity', () => { 127 + let wb = noSnapWhiteboard(); 128 + wb = addShape(wb, 'diamond', 0, 0, 200, 150, 'Test Label'); 129 + const id = [...wb.shapes.keys()][0]; 130 + 131 + // Set some style 132 + wb = setShapeStyle(wb, [id], { fill: 'red', stroke: 'blue' }); 133 + 134 + const before = wb.shapes.get(id)!; 135 + 136 + wb = moveShape(wb, id, 500, 300); 137 + const after = wb.shapes.get(id)!; 138 + 139 + expect(after.x).toBe(500); 140 + expect(after.y).toBe(300); 141 + expect(after.kind).toBe(before.kind); 142 + expect(after.width).toBe(before.width); 143 + expect(after.height).toBe(before.height); 144 + expect(after.rotation).toBe(before.rotation); 145 + expect(after.label).toBe(before.label); 146 + expect(after.style).toEqual(before.style); 147 + expect(after.opacity).toBe(before.opacity); 148 + }); 149 + }); 150 + 151 + // ===================================================================== 152 + // 4. RESIZE SHAPE TO NEGATIVE DIMENSIONS — clamped to minimum 153 + // ===================================================================== 154 + 155 + describe('Whiteboard integrity — resize to negative dimensions', () => { 156 + it('resizeShape clamps width to minimum 10', () => { 157 + let wb = noSnapWhiteboard(); 158 + wb = addShape(wb, 'rectangle', 0, 0, 100, 100); 159 + const id = [...wb.shapes.keys()][0]; 160 + 161 + wb = resizeShape(wb, id, -50, 100); 162 + expect(wb.shapes.get(id)!.width).toBe(10); 163 + }); 164 + 165 + it('resizeShape clamps height to minimum 10', () => { 166 + let wb = noSnapWhiteboard(); 167 + wb = addShape(wb, 'rectangle', 0, 0, 100, 100); 168 + const id = [...wb.shapes.keys()][0]; 169 + 170 + wb = resizeShape(wb, id, 100, -50); 171 + expect(wb.shapes.get(id)!.height).toBe(10); 172 + }); 173 + 174 + it('resizeShape clamps both dimensions to minimum 10', () => { 175 + let wb = noSnapWhiteboard(); 176 + wb = addShape(wb, 'rectangle', 0, 0, 100, 100); 177 + const id = [...wb.shapes.keys()][0]; 178 + 179 + wb = resizeShape(wb, id, -100, -200); 180 + expect(wb.shapes.get(id)!.width).toBe(10); 181 + expect(wb.shapes.get(id)!.height).toBe(10); 182 + }); 183 + 184 + it('resizeShape to exactly 0 clamps to 10', () => { 185 + let wb = noSnapWhiteboard(); 186 + wb = addShape(wb, 'rectangle', 0, 0, 100, 100); 187 + const id = [...wb.shapes.keys()][0]; 188 + 189 + wb = resizeShape(wb, id, 0, 0); 190 + expect(wb.shapes.get(id)!.width).toBe(10); 191 + expect(wb.shapes.get(id)!.height).toBe(10); 192 + }); 193 + }); 194 + 195 + // ===================================================================== 196 + // 5. MULTIPLE ARROWS BETWEEN SAME TWO SHAPES 197 + // ===================================================================== 198 + 199 + describe('Whiteboard integrity — multiple arrows between same shapes', () => { 200 + it('two arrows between same shapes both coexist', () => { 201 + let wb = noSnapWhiteboard(); 202 + wb = addShape(wb, 'rectangle', 0, 0); 203 + wb = addShape(wb, 'rectangle', 200, 0); 204 + const [id1, id2] = [...wb.shapes.keys()]; 205 + 206 + wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 207 + wb = addArrow(wb, { shapeId: id1, anchor: 'bottom' }, { shapeId: id2, anchor: 'top' }); 208 + 209 + expect(wb.arrows.size).toBe(2); 210 + 211 + // Both arrows connect to both shapes 212 + const arrowsForS1 = arrowsForShape(wb, id1); 213 + const arrowsForS2 = arrowsForShape(wb, id2); 214 + expect(arrowsForS1).toHaveLength(2); 215 + expect(arrowsForS2).toHaveLength(2); 216 + }); 217 + 218 + it('removing one arrow does not remove the other', () => { 219 + let wb = noSnapWhiteboard(); 220 + wb = addShape(wb, 'rectangle', 0, 0); 221 + wb = addShape(wb, 'rectangle', 200, 0); 222 + const [id1, id2] = [...wb.shapes.keys()]; 223 + 224 + wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 225 + wb = addArrow(wb, { shapeId: id1, anchor: 'bottom' }, { shapeId: id2, anchor: 'top' }); 226 + 227 + const arrowIds = [...wb.arrows.keys()]; 228 + wb = removeArrow(wb, arrowIds[0]); 229 + 230 + expect(wb.arrows.size).toBe(1); 231 + expect(arrowsForShape(wb, id1)).toHaveLength(1); 232 + expect(arrowsForShape(wb, id2)).toHaveLength(1); 233 + }); 234 + 235 + it('three arrows with different anchors all coexist', () => { 236 + let wb = noSnapWhiteboard(); 237 + wb = addShape(wb, 'rectangle', 0, 0); 238 + wb = addShape(wb, 'rectangle', 200, 0); 239 + const [id1, id2] = [...wb.shapes.keys()]; 240 + 241 + wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 242 + wb = addArrow(wb, { shapeId: id1, anchor: 'top' }, { shapeId: id2, anchor: 'bottom' }); 243 + wb = addArrow(wb, { shapeId: id1, anchor: 'center' }, { shapeId: id2, anchor: 'center' }); 244 + 245 + expect(wb.arrows.size).toBe(3); 246 + const arrows = [...wb.arrows.values()]; 247 + const ids = new Set(arrows.map(a => a.id)); 248 + expect(ids.size).toBe(3); 249 + }); 250 + }); 251 + 252 + // ===================================================================== 253 + // 6. SHAPE STYLE MERGING — setting fill doesn't remove stroke 254 + // ===================================================================== 255 + 256 + describe('Whiteboard integrity — style merging', () => { 257 + it('setShapeStyle merges new properties without removing existing ones', () => { 258 + let wb = noSnapWhiteboard(); 259 + wb = addShape(wb, 'rectangle', 0, 0); 260 + const id = [...wb.shapes.keys()][0]; 261 + 262 + // First set: add fill and stroke 263 + wb = setShapeStyle(wb, [id], { fill: 'red', stroke: 'blue' }); 264 + expect(wb.shapes.get(id)!.style).toEqual({ fill: 'red', stroke: 'blue' }); 265 + 266 + // Second set: add border-radius — fill and stroke should still be there 267 + wb = setShapeStyle(wb, [id], { 'border-radius': '5px' }); 268 + const style = wb.shapes.get(id)!.style; 269 + expect(style.fill).toBe('red'); 270 + expect(style.stroke).toBe('blue'); 271 + expect(style['border-radius']).toBe('5px'); 272 + }); 273 + 274 + it('setShapeStyle can override an existing property', () => { 275 + let wb = noSnapWhiteboard(); 276 + wb = addShape(wb, 'rectangle', 0, 0); 277 + const id = [...wb.shapes.keys()][0]; 278 + 279 + wb = setShapeStyle(wb, [id], { fill: 'red' }); 280 + wb = setShapeStyle(wb, [id], { fill: 'green' }); 281 + 282 + expect(wb.shapes.get(id)!.style.fill).toBe('green'); 283 + }); 284 + 285 + it('setShapeStyle on multiple shapes applies to all', () => { 286 + let wb = noSnapWhiteboard(); 287 + wb = addShape(wb, 'rectangle', 0, 0); 288 + wb = addShape(wb, 'ellipse', 100, 0); 289 + const ids = [...wb.shapes.keys()]; 290 + 291 + wb = setShapeStyle(wb, ids, { fill: 'yellow' }); 292 + 293 + for (const id of ids) { 294 + expect(wb.shapes.get(id)!.style.fill).toBe('yellow'); 295 + } 296 + }); 297 + }); 298 + 299 + // ===================================================================== 300 + // 7. EMPTY LABEL VS UNDEFINED LABEL HANDLING 301 + // ===================================================================== 302 + 303 + describe('Whiteboard integrity — label handling', () => { 304 + it('shape created without label has empty string label', () => { 305 + let wb = noSnapWhiteboard(); 306 + wb = addShape(wb, 'rectangle', 0, 0); 307 + const shape = [...wb.shapes.values()][0]; 308 + expect(shape.label).toBe(''); 309 + }); 310 + 311 + it('shape created with empty string label has empty string', () => { 312 + let wb = noSnapWhiteboard(); 313 + wb = addShape(wb, 'rectangle', 0, 0, 120, 80, ''); 314 + const shape = [...wb.shapes.values()][0]; 315 + expect(shape.label).toBe(''); 316 + }); 317 + 318 + it('setShapeLabel to empty string clears the label', () => { 319 + let wb = noSnapWhiteboard(); 320 + wb = addShape(wb, 'rectangle', 0, 0, 120, 80, 'Initial'); 321 + const id = [...wb.shapes.keys()][0]; 322 + 323 + wb = setShapeLabel(wb, id, ''); 324 + expect(wb.shapes.get(id)!.label).toBe(''); 325 + }); 326 + 327 + it('setShapeLabel preserves other shape properties', () => { 328 + let wb = noSnapWhiteboard(); 329 + wb = addShape(wb, 'diamond', 50, 60, 200, 150); 330 + const id = [...wb.shapes.keys()][0]; 331 + const before = wb.shapes.get(id)!; 332 + 333 + wb = setShapeLabel(wb, id, 'New Label'); 334 + const after = wb.shapes.get(id)!; 335 + 336 + expect(after.label).toBe('New Label'); 337 + expect(after.kind).toBe(before.kind); 338 + expect(after.x).toBe(before.x); 339 + expect(after.y).toBe(before.y); 340 + expect(after.width).toBe(before.width); 341 + expect(after.height).toBe(before.height); 342 + }); 343 + }); 344 + 345 + // ===================================================================== 346 + // 8. RAPID ADD/REMOVE CYCLES — no dangling references 347 + // ===================================================================== 348 + 349 + describe('Whiteboard integrity — rapid add/remove cycles', () => { 350 + it('50 add/remove cycles leave no shapes or arrows', () => { 351 + let wb = noSnapWhiteboard(); 352 + 353 + for (let i = 0; i < 50; i++) { 354 + wb = addShape(wb, 'rectangle', i * 10, i * 10); 355 + const id = [...wb.shapes.keys()].pop()!; 356 + wb = addArrow(wb, { shapeId: id, anchor: 'right' }, { x: 999, y: 999 }); 357 + wb = removeShape(wb, id); 358 + } 359 + 360 + expect(wb.shapes.size).toBe(0); 361 + expect(wb.arrows.size).toBe(0); 362 + }); 363 + 364 + it('add 50 shapes then remove all — no dangling arrows', () => { 365 + let wb = noSnapWhiteboard(); 366 + const ids: string[] = []; 367 + 368 + // Add 50 shapes 369 + for (let i = 0; i < 50; i++) { 370 + wb = addShape(wb, 'rectangle', i * 10, 0); 371 + ids.push([...wb.shapes.keys()].pop()!); 372 + } 373 + 374 + // Add arrows between consecutive shapes 375 + for (let i = 0; i < 49; i++) { 376 + wb = addArrow(wb, { shapeId: ids[i], anchor: 'right' }, { shapeId: ids[i + 1], anchor: 'left' }); 377 + } 378 + expect(wb.arrows.size).toBe(49); 379 + 380 + // Remove all shapes one by one 381 + for (const id of ids) { 382 + wb = removeShape(wb, id); 383 + } 384 + 385 + expect(wb.shapes.size).toBe(0); 386 + expect(wb.arrows.size).toBe(0); 387 + }); 388 + 389 + it('interleaved adds and removes maintain consistent count', () => { 390 + let wb = noSnapWhiteboard(); 391 + 392 + // Add 10 shapes 393 + for (let i = 0; i < 10; i++) { 394 + wb = addShape(wb, 'rectangle', i * 10, 0); 395 + } 396 + expect(wb.shapes.size).toBe(10); 397 + 398 + // Remove 5 399 + const ids = [...wb.shapes.keys()]; 400 + for (let i = 0; i < 5; i++) { 401 + wb = removeShape(wb, ids[i]); 402 + } 403 + expect(wb.shapes.size).toBe(5); 404 + 405 + // Add 3 more 406 + for (let i = 0; i < 3; i++) { 407 + wb = addShape(wb, 'ellipse', i * 10, 100); 408 + } 409 + expect(wb.shapes.size).toBe(8); 410 + 411 + // Verify all remaining shapes have valid data 412 + for (const shape of wb.shapes.values()) { 413 + expect(shape.id).toBeTruthy(); 414 + expect(typeof shape.x).toBe('number'); 415 + expect(typeof shape.y).toBe('number'); 416 + expect(shape.width).toBeGreaterThanOrEqual(10); 417 + expect(shape.height).toBeGreaterThanOrEqual(10); 418 + } 419 + }); 420 + 421 + it('arrowsForShape returns empty for a deleted shape ID', () => { 422 + let wb = noSnapWhiteboard(); 423 + wb = addShape(wb, 'rectangle', 0, 0); 424 + wb = addShape(wb, 'rectangle', 200, 0); 425 + const [id1, id2] = [...wb.shapes.keys()]; 426 + wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 427 + 428 + wb = removeShape(wb, id1); 429 + 430 + // id1 no longer exists — arrowsForShape should return empty 431 + expect(arrowsForShape(wb, id1)).toHaveLength(0); 432 + }); 433 + });
+385
tests/xss-prevention.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + describeAction, validateAction, parseActions, 4 + type AIAction, 5 + } from '../src/lib/ai-actions.js'; 6 + 7 + /** 8 + * XSS prevention tests — verifies that user/AI-supplied content cannot 9 + * escape through the action system in dangerous ways. 10 + * 11 + * The current AIAction union covers DocAction | SheetAction only. 12 + */ 13 + 14 + describe('describeAction: long text truncation', () => { 15 + it('truncates doc_replace search text at 40 chars with ellipsis', () => { 16 + const action: AIAction = { 17 + type: 'doc_replace', 18 + search: 'A'.repeat(100), 19 + replace: 'B', 20 + }; 21 + const desc = describeAction(action); 22 + expect(desc).toContain('A'.repeat(40)); 23 + expect(desc).toContain('...'); 24 + expect(desc).not.toContain('A'.repeat(41)); 25 + }); 26 + 27 + it('does not add ellipsis for search text at exactly 40 chars', () => { 28 + const action: AIAction = { 29 + type: 'doc_replace', 30 + search: 'X'.repeat(40), 31 + replace: '', 32 + }; 33 + const desc = describeAction(action); 34 + expect(desc).not.toContain('...'); 35 + }); 36 + 37 + it('does not truncate search text shorter than 40 chars', () => { 38 + const action: AIAction = { 39 + type: 'doc_replace', 40 + search: 'short search', 41 + replace: 'replacement', 42 + }; 43 + const desc = describeAction(action); 44 + expect(desc).toContain('short search'); 45 + expect(desc).not.toContain('...'); 46 + }); 47 + 48 + it('truncates doc_suggest_replace search text at 40 chars', () => { 49 + const action: AIAction = { 50 + type: 'doc_suggest_replace', 51 + search: 'B'.repeat(60), 52 + replace: 'C', 53 + }; 54 + const desc = describeAction(action); 55 + expect(desc).toContain('B'.repeat(40)); 56 + expect(desc).toContain('...'); 57 + }); 58 + 59 + it('HTML in search text is just truncated, not interpreted', () => { 60 + const action: AIAction = { 61 + type: 'doc_replace', 62 + search: '<script>alert("xss")</script>'.repeat(5), 63 + replace: 'safe', 64 + }; 65 + const desc = describeAction(action); 66 + // The description is a plain string — it should contain the raw HTML chars 67 + expect(desc).toContain('<script>'); 68 + expect(desc.length).toBeLessThan(200); // bounded output 69 + }); 70 + 71 + it('sheet_set description truncates cell list at 3 cells', () => { 72 + const action: AIAction = { 73 + type: 'sheet_set', 74 + cells: [ 75 + { ref: 'A1', value: 'v1' }, 76 + { ref: 'B2', value: 'v2' }, 77 + { ref: 'C3', value: 'v3' }, 78 + { ref: 'D4', value: 'v4' }, 79 + { ref: 'E5', value: 'v5' }, 80 + ], 81 + }; 82 + const desc = describeAction(action); 83 + expect(desc).toContain('5 cells'); 84 + expect(desc).toContain('A1'); 85 + expect(desc).toContain('B2'); 86 + expect(desc).toContain('C3'); 87 + expect(desc).toContain('...'); 88 + expect(desc).not.toContain('D4'); 89 + }); 90 + }); 91 + 92 + describe('validateAction: type field validation', () => { 93 + it('rejects action with HTML in the type field', () => { 94 + const result = validateAction({ 95 + type: '<img src=x onerror=alert(1)>', 96 + content: 'hello', 97 + position: 'end', 98 + }); 99 + expect(result.valid).toBe(false); 100 + expect(result.error).toContain('Unknown action type'); 101 + }); 102 + 103 + it('rejects action with script tag in type field', () => { 104 + const result = validateAction({ 105 + type: '<script>alert("xss")</script>', 106 + }); 107 + expect(result.valid).toBe(false); 108 + }); 109 + 110 + it('rejects null action', () => { 111 + const result = validateAction(null); 112 + expect(result.valid).toBe(false); 113 + }); 114 + 115 + it('rejects undefined action', () => { 116 + const result = validateAction(undefined); 117 + expect(result.valid).toBe(false); 118 + }); 119 + 120 + it('rejects non-object action (string)', () => { 121 + const result = validateAction('not an object'); 122 + expect(result.valid).toBe(false); 123 + }); 124 + 125 + it('rejects non-object action (number)', () => { 126 + const result = validateAction(42); 127 + expect(result.valid).toBe(false); 128 + }); 129 + 130 + it('rejects action with numeric type', () => { 131 + const result = validateAction({ type: 42 }); 132 + expect(result.valid).toBe(false); 133 + expect(result.error).toContain('string "type" field'); 134 + }); 135 + 136 + it('rejects action with no type field', () => { 137 + const result = validateAction({ content: 'hello' }); 138 + expect(result.valid).toBe(false); 139 + }); 140 + 141 + it('rejects action with completely unknown type', () => { 142 + const result = validateAction({ type: 'unknown_action_type' }); 143 + expect(result.valid).toBe(false); 144 + expect(result.error).toContain('Unknown action type'); 145 + }); 146 + 147 + it('rejects doc_insert with missing position', () => { 148 + const result = validateAction({ 149 + type: 'doc_insert', 150 + content: 'hello', 151 + }); 152 + expect(result.valid).toBe(false); 153 + expect(result.error).toContain('position'); 154 + }); 155 + 156 + it('rejects doc_insert with invalid position', () => { 157 + const result = validateAction({ 158 + type: 'doc_insert', 159 + content: 'hello', 160 + position: 'middle', 161 + }); 162 + expect(result.valid).toBe(false); 163 + }); 164 + 165 + it('rejects doc_insert with empty content', () => { 166 + const result = validateAction({ 167 + type: 'doc_insert', 168 + content: '', 169 + position: 'end', 170 + }); 171 + expect(result.valid).toBe(false); 172 + expect(result.error).toContain('non-empty "content"'); 173 + }); 174 + 175 + it('rejects doc_replace with empty search', () => { 176 + const result = validateAction({ 177 + type: 'doc_replace', 178 + search: '', 179 + replace: 'new', 180 + }); 181 + expect(result.valid).toBe(false); 182 + }); 183 + 184 + it('rejects sheet_set with empty cells array', () => { 185 + const result = validateAction({ 186 + type: 'sheet_set', 187 + cells: [], 188 + }); 189 + expect(result.valid).toBe(false); 190 + }); 191 + 192 + it('rejects sheet_set with invalid cell ref', () => { 193 + const result = validateAction({ 194 + type: 'sheet_set', 195 + cells: [{ ref: 'not-a-ref', value: 'x' }], 196 + }); 197 + expect(result.valid).toBe(false); 198 + expect(result.error).toContain('ref is invalid'); 199 + }); 200 + 201 + it('rejects sheet_clear with invalid range', () => { 202 + const result = validateAction({ 203 + type: 'sheet_clear', 204 + range: 'invalid', 205 + }); 206 + expect(result.valid).toBe(false); 207 + }); 208 + 209 + it('accepts valid doc_insert action', () => { 210 + const result = validateAction({ 211 + type: 'doc_insert', 212 + content: '<script>alert(1)</script>', 213 + position: 'end', 214 + }); 215 + // The content can contain HTML — validation only checks structure, not content safety 216 + expect(result.valid).toBe(true); 217 + }); 218 + }); 219 + 220 + describe('parseActions: script tags in JSON values', () => { 221 + it('parses action with script tags in content value', () => { 222 + const text = '```action\n{"type": "doc_insert", "position": "end", "content": "<script>alert(1)</script>"}\n```'; 223 + const { actions, errors } = parseActions(text); 224 + expect(errors.length).toBe(0); 225 + expect(actions.length).toBe(1); 226 + expect(actions[0]!.type).toBe('doc_insert'); 227 + expect((actions[0] as { content: string }).content).toBe('<script>alert(1)</script>'); 228 + }); 229 + 230 + it('parses action with HTML img tag in content', () => { 231 + const text = '```action\n{"type": "doc_insert", "position": "start", "content": "<img src=x onerror=alert(1)>"}\n```'; 232 + const { actions, errors } = parseActions(text); 233 + expect(errors.length).toBe(0); 234 + expect(actions.length).toBe(1); 235 + }); 236 + 237 + it('parses action with event handler attributes in search field', () => { 238 + const text = '```action\n{"type": "doc_replace", "search": "onclick=\\"alert(1)\\"", "replace": "safe"}\n```'; 239 + const { actions, errors } = parseActions(text); 240 + expect(errors.length).toBe(0); 241 + expect(actions.length).toBe(1); 242 + }); 243 + 244 + it('reports error for non-JSON content that looks like script injection', () => { 245 + const text = '```action\n<script>alert("xss")</script>\n```'; 246 + const { actions, errors } = parseActions(text); 247 + expect(actions.length).toBe(0); 248 + expect(errors.length).toBe(1); 249 + expect(errors[0]).toContain('Malformed JSON'); 250 + }); 251 + 252 + it('parses multiple action blocks, some with injection content', () => { 253 + const text = [ 254 + 'Some text', 255 + '```action', 256 + '{"type": "doc_insert", "position": "end", "content": "normal"}', 257 + '```', 258 + 'More text', 259 + '```action', 260 + '{"type": "doc_replace", "search": "<img onerror=alert(1)>", "replace": "safe"}', 261 + '```', 262 + ].join('\n'); 263 + const { actions, errors } = parseActions(text); 264 + expect(errors.length).toBe(0); 265 + expect(actions.length).toBe(2); 266 + }); 267 + 268 + it('rejects unknown action types inside action blocks', () => { 269 + const text = '```action\n{"type": "execute_code", "code": "rm -rf /"}\n```'; 270 + const { actions, errors } = parseActions(text); 271 + expect(actions.length).toBe(0); 272 + expect(errors.length).toBe(1); 273 + expect(errors[0]).toContain('Unknown action type'); 274 + }); 275 + 276 + it('handles empty action block', () => { 277 + const text = '```action\n\n```'; 278 + const { actions, errors } = parseActions(text); 279 + expect(actions.length).toBe(0); 280 + expect(errors.length).toBe(1); 281 + }); 282 + 283 + it('ignores non-action code blocks', () => { 284 + const text = '```json\n{"type": "doc_insert", "position": "end", "content": "ignored"}\n```'; 285 + const { actions, errors } = parseActions(text); 286 + expect(actions.length).toBe(0); 287 + expect(errors.length).toBe(0); 288 + }); 289 + }); 290 + 291 + describe('HEX_COLOR_RE: color sanitization regex', () => { 292 + // The HEX_COLOR_RE regex /^#[0-9a-fA-F]{3,8}$/ is used in diagram and slide 293 + // action executors to sanitize fill/stroke color values. 294 + // We test it directly to verify the security boundary. 295 + 296 + const HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/; 297 + 298 + describe('valid hex colors', () => { 299 + it('accepts #fff (3-char shorthand)', () => { 300 + expect(HEX_COLOR_RE.test('#fff')).toBe(true); 301 + }); 302 + 303 + it('accepts #FFF (uppercase 3-char)', () => { 304 + expect(HEX_COLOR_RE.test('#FFF')).toBe(true); 305 + }); 306 + 307 + it('accepts #FFFFFF (6-char)', () => { 308 + expect(HEX_COLOR_RE.test('#FFFFFF')).toBe(true); 309 + }); 310 + 311 + it('accepts #aabbcc (lowercase 6-char)', () => { 312 + expect(HEX_COLOR_RE.test('#aabbcc')).toBe(true); 313 + }); 314 + 315 + it('accepts #12345678 (8-char with alpha)', () => { 316 + expect(HEX_COLOR_RE.test('#12345678')).toBe(true); 317 + }); 318 + 319 + it('accepts #abcd (4-char with alpha shorthand)', () => { 320 + expect(HEX_COLOR_RE.test('#abcd')).toBe(true); 321 + }); 322 + }); 323 + 324 + describe('invalid/dangerous color values', () => { 325 + it('rejects #GGG (non-hex chars)', () => { 326 + expect(HEX_COLOR_RE.test('#GGG')).toBe(false); 327 + }); 328 + 329 + it('rejects # (hash only)', () => { 330 + expect(HEX_COLOR_RE.test('#')).toBe(false); 331 + }); 332 + 333 + it('rejects ##fff (double hash)', () => { 334 + expect(HEX_COLOR_RE.test('##fff')).toBe(false); 335 + }); 336 + 337 + it('rejects rgb(0,0,0)', () => { 338 + expect(HEX_COLOR_RE.test('rgb(0,0,0)')).toBe(false); 339 + }); 340 + 341 + it('rejects empty string', () => { 342 + expect(HEX_COLOR_RE.test('')).toBe(false); 343 + }); 344 + 345 + it('rejects url(javascript:alert(1))', () => { 346 + expect(HEX_COLOR_RE.test('url(javascript:alert(1))')).toBe(false); 347 + }); 348 + 349 + it('rejects expression(...)', () => { 350 + expect(HEX_COLOR_RE.test('expression(alert(1))')).toBe(false); 351 + }); 352 + 353 + it('rejects <script> tag as color', () => { 354 + expect(HEX_COLOR_RE.test('<script>alert(1)</script>')).toBe(false); 355 + }); 356 + 357 + it('rejects CSS injection: red; background-image: url(...)', () => { 358 + expect(HEX_COLOR_RE.test('red; background-image: url(evil.js)')).toBe(false); 359 + }); 360 + 361 + it('rejects color with trailing semicolon injection', () => { 362 + expect(HEX_COLOR_RE.test('#fff; color: red')).toBe(false); 363 + }); 364 + 365 + it('rejects hex color without leading hash', () => { 366 + expect(HEX_COLOR_RE.test('ffffff')).toBe(false); 367 + }); 368 + 369 + it('rejects #123456789 (9 chars, too long)', () => { 370 + expect(HEX_COLOR_RE.test('#123456789')).toBe(false); 371 + }); 372 + 373 + it('rejects #ab (2 hex chars, too short)', () => { 374 + expect(HEX_COLOR_RE.test('#ab')).toBe(false); 375 + }); 376 + 377 + it('rejects -moz-binding:url(...)', () => { 378 + expect(HEX_COLOR_RE.test('-moz-binding:url(evil)')).toBe(false); 379 + }); 380 + 381 + it('rejects behavior:url(...)', () => { 382 + expect(HEX_COLOR_RE.test('behavior:url(evil.htc)')).toBe(false); 383 + }); 384 + }); 385 + });