diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index c77cc35..0dc62f0 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -51,6 +51,7 @@ jobs: npm run test:ui npm run test:photo npm run test:sleek + npm run test:portability - name: Dependency audit run: npm audit --audit-level=high - name: Syntax checks diff --git a/.gitignore b/.gitignore index a8758b0..8625199 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ *.pyc .env .env.* +.worktrees/ diff --git a/app.js b/app.js index 1d9c082..b82f6b4 100644 --- a/app.js +++ b/app.js @@ -1,4 +1,4 @@ -import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js'; +import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, estimateLedgerBytes, exportLedger, hasUrgentLedgerContext, importLedger, MAX_IMPORT_BYTES, mergeLedgers, migrateStoredLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage, utf8ByteLength } from './src/domain.js'; import { mergeVisualSuggestion } from './src/analysis.js'; const runtimeConfig = { @@ -35,8 +35,18 @@ const draft = () => ({ bristolType: 4, color: 'brown', urgency: 0, discomfort: 0 let form = draft(); function esc(value='') { return String(value).replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); } -function loadEntries() { try { return JSON.parse(localStorage.getItem(STORE) || '[]'); } catch { return []; } } -function saveEntries() { localStorage.setItem(STORE, JSON.stringify(entries)); } +function loadEntries() { + // Storage is validated and migrated BEFORE any render: malformed history + // (invalid dates, duplicate ids, junk rows) is repaired here so it can never + // blank the UI or crash boot. Healthy storage round-trips untouched. + const raw = localStorage.getItem(STORE); + const { entries: migrated, changed } = migrateStoredLedger(raw); + if (changed) persistLedger(migrated); + return migrated; +} +// Persistence is the single choke point for ledger writes so every caller +// gets identical quota handling. +function persistLedger(nextEntries) { localStorage.setItem(STORE, JSON.stringify(nextEntries)); } function formatDate(value) { return new Intl.DateTimeFormat(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}).format(new Date(value)); } function toast(message) { const node=document.createElement('div');node.className='toast';node.textContent=message;document.body.append(node);setTimeout(()=>node.remove(),2400); } @@ -119,8 +129,29 @@ function privacy(){ document.querySelector('#export').onclick=exportData;document.querySelector('#import').onchange=importData;document.querySelector('#delete-all').onclick=deleteData; } function exportData(){const blob=new Blob([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');} -async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}} -function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}} +async function importData(e){ + try{ + const file=e.target.files[0];if(!file)return; + if(file.size>MAX_IMPORT_BYTES)throw new RangeError('That file is too large to be a Timmy export.'); + // Transactional import: parse and merge into a CANDIDATE ledger first. + // Nothing user-visible changes until the candidate is fully persisted, so + // a quota failure or any error rolls back memory and storage together. + const text=await file.text(); + const result=mergeLedgers(entries,importLedger(text)); + if(estimateLedgerBytes(result.merged)>MAX_IMPORT_BYTES||utf8ByteLength(JSON.stringify(entries))>MAX_IMPORT_BYTES){ + throw new RangeError('That file is too large to fit in your portable local storage.'); + } + persistLedger(result.merged); + entries=result.merged; + render(); + toast(result.added.length?`Ledger imported: ${result.added.length} new log${result.added.length===1?'':'s'}`:`Already in your ledger: ${result.skippedIds.length} log${result.skippedIds.length===1?'':'s'} skipped (kept your saved version)`) + }catch(err){ + // Roll back in-memory state to whatever storage actually holds. + try{entries=migrateStoredLedger(localStorage.getItem(STORE)).entries}catch{entries=entries} + toast(err&&err.name==='QuotaExceededError'?`Import failed: your browser storage is full. Export your ledger, delete some large photos, and try again.`:(err?.message||'Import failed.')) + } +} +function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);if(BASE_PATH==='/')localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}} function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()} async function loadVisionStatus(){ @@ -173,7 +204,7 @@ function bindStep(step){ } async function handlePhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showLogStep(2)}catch{photoHint='That image could not be read. Try another photo.';showLogStep(2)}} function compressPhoto(file){return new Promise((resolve,reject)=>{const img=new Image(),url=URL.createObjectURL(file);img.onload=()=>{const scale=Math.min(1,1200/Math.max(img.width,img.height)),canvas=document.createElement('canvas');canvas.width=Math.round(img.width*scale);canvas.height=Math.round(img.height*scale);const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0,canvas.width,canvas.height);const sample=ctx.getImageData(0,0,Math.min(canvas.width,120),Math.min(canvas.height,120)).data;let total=0;for(let i=0;i{URL.revokeObjectURL(url);reject()};img.src=url})} -function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=sanitizeEntry({...form,photoDataUrl});try{entries.push(entry);saveEntries()}catch{entry.photoDataUrl='';entries[entries.length-1]=entry;saveEntries();toast('Log saved, but the photo was too large for browser storage')}document.querySelector('.sheet-backdrop')?.remove();view='home';render();toast(result.urgent?'Saved. Please follow the medical-care alert.':'Private log saved')} +function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=sanitizeEntry({...form,photoDataUrl});try{entries.push(entry);persistLedger(entries)}catch{entry.photoDataUrl='';entries[entries.length-1]=entry;persistLedger(entries);toast('Log saved, but the photo was too large for browser storage')}document.querySelector('.sheet-backdrop')?.remove();view='home';render();toast(result.urgent?'Saved. Please follow the medical-care alert.':'Private log saved')} function render(){({home,calendar,timmy,privacy}[view]||home)()} render(); diff --git a/artifacts/portability-import-mobile.png b/artifacts/portability-import-mobile.png new file mode 100644 index 0000000..8313356 Binary files /dev/null and b/artifacts/portability-import-mobile.png differ diff --git a/package.json b/package.json index 31839e0..e5a2754 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "test:ui": "node tests/ui.acceptance.mjs", "test:photo": "node tests/photo-first.acceptance.mjs", "test:sleek": "node tests/sleek-chat.acceptance.mjs", + "test:portability": "node tests/ledger-portability.acceptance.mjs", "test:staging-smoke": "node tests/staging.acceptance.mjs", "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py", "check:diff": "bash scripts/check_diff.sh", diff --git a/service-worker.js b/service-worker.js index bbe69e7..6b40bd3 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,7 +1,7 @@ const ROOT = new URL(self.registration.scope).pathname; const appPath = path => `${ROOT}${String(path).replace(/^\/+/, '')}`; const CACHE_NAMESPACE = `timmy-shell:${ROOT}:`; -const CACHE = `${CACHE_NAMESPACE}v5`; +const CACHE = `${CACHE_NAMESPACE}v7`; const ASSETS = [ '', 'index.html', diff --git a/src/analysis.js b/src/analysis.js index 8053cfc..e36d019 100644 --- a/src/analysis.js +++ b/src/analysis.js @@ -42,7 +42,7 @@ export function parseVisionResponse(raw) { export function mergeVisualSuggestion(form, suggestion) { if (suggestion?.status !== 'suggestion') return { ...form }; - return { ...form, bristolType: suggestion.bristolType, color: suggestion.color }; + return { ...form, bristolType: suggestion.bristolType, color: suggestion.color, provenance: { origin: 'ai-suggestion' } }; } export function validatePhotoPayload(payload = {}) { diff --git a/src/domain.js b/src/domain.js index a7b7832..76a7c9c 100644 --- a/src/domain.js +++ b/src/domain.js @@ -1,4 +1,6 @@ const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas']; +const UTF8_ENCODER = new TextEncoder(); +const KNOWN_PROVENANCE_ORIGINS = Object.freeze(new Set(['user', 'ai-suggestion'])); const URGENT_MESSAGE = 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.'; const URGENT_TEXT_PATTERNS = Object.freeze([ ['blood', /\b(?:rectal bleeding|bleeding from (?:the )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the )?(?:stool|poop|bowel movement)|(?:stool|poop) (?:has|contains|with) blood)\b/i], @@ -53,21 +55,128 @@ export function buildTimmySummary(entries = []) { return `${pieces.join(' · ')}. Patterns matter more than one entry. You choose what to eat; I only help you notice changes.`; } +// Current-schema strictness: numeric fields must be true integers inside the +// clinical bounds (no silent clamping of out-of-range values), and photos must +// be canonical raster JPEG/PNG/WebP data URLs only. + +// Canonical raster photo contract: +// - strict data URL grammar for exactly image/jpeg, image/png, image/webp; +// - base64 must be canonical (round-trip byte-exact, no whitespace, no +// base64url alphabet, no excess padding), so smuggled encodings fail; +// - decoded binary carries the declared format's real magic signature, so a +// mislabeled SVG/HTML/text payload can never pose as a raster photo; +// - decoded size stays within the app-wide 4 MiB photo ceiling (analysis.js) +// and above trivial-junk size, so noncanonical tiny blobs are rejected. +const MAX_PHOTO_DECODED_BYTES = 4 * 1024 * 1024; +const MIN_PHOTO_DECODED_BYTES = 32; +const PNG_MAGIC = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; +const JPEG_MAGIC = [0xFF, 0xD8, 0xFF]; +const PHOTO_DATA_URL = /^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/]+={0,2})$/; +const BASE64_CHAR = /[A-Za-z0-9+/]/; + +// Linear-time canonical-base64 check: standard alphabet only, length a +// multiple of four, padding only ever as one trailing '=' or two '=='. +// A regex quantifier loop is avoided deliberately: multi-megabyte photo +// payloads must not depend on regexp engine internals. +function isCanonicalBase64(text) { + const { length } = text; + if (length === 0 || length % 4 !== 0) return false; + let padStart = length; + if (text[length - 1] === '=') { + padStart = text[length - 2] === '=' ? length - 2 : length - 1; + if (!BASE64_CHAR.test(text[padStart - 1])) return false; + } + for (let i = 0; i < padStart; i += 1) { + if (!BASE64_CHAR.test(text[i])) return false; + } + return true; +} + +function asciiSignature(bytes, start, end) { + return String.fromCharCode(...bytes.subarray(start, end)); +} + +function canonicalRasterPhoto(value) { + if (typeof value !== 'string') return ''; + const match = PHOTO_DATA_URL.exec(value); + if (!match) return ''; + const [, mime, base64] = match; + if (!isCanonicalBase64(base64)) return ''; + let bytes; + try { + // Decode once and verify canonicality arithmetically: standard alphabet + // plus exact trailing padding decodes to the same bytes every time, so a + // byte-exact re-encode is implied by the grammar checks above. This avoids + // btoa entirely (it throws on chars above U+00FF) and avoids multi-MB + // string churn on large photos. + const decoded = atob(base64); + bytes = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i += 1) bytes[i] = decoded.charCodeAt(i); + } catch { + return ''; + } + if (bytes.length < MIN_PHOTO_DECODED_BYTES || bytes.length > MAX_PHOTO_DECODED_BYTES) return ''; + if (mime === 'image/jpeg') { + if (bytes.length < JPEG_MAGIC.length || !JPEG_MAGIC.every((byte, index) => bytes[index] === byte)) return ''; + } else if (mime === 'image/png') { + if (bytes.length < PNG_MAGIC.length || !PNG_MAGIC.every((byte, index) => bytes[index] === byte)) return ''; + } else if (asciiSignature(bytes, 0, 4) !== 'RIFF' || asciiSignature(bytes, 8, 12) !== 'WEBP') { + return ''; + } + return value; +} + +function toSchemaInteger(value, { min, max, fallback }) { + if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) return fallback; + return value; +} + +// Invalid, missing, or actively hostile dates must never throw and never +// persist Invalid Date values: anything that cannot be read safely becomes a +// fresh valid ISO timestamp instead. +export function safeIsoTimestamp(value) { + try { + if (value instanceof Date) { + const time = value.getTime(); + if (Number.isFinite(time)) return new Date(time).toISOString(); + } else if (typeof value === 'string' || typeof value === 'number') { + const parsed = new Date(value); + const time = parsed.getTime(); + if (Number.isFinite(time)) return parsed.toISOString(); + } + } catch { /* hostile toString/getTime/valueOf stays contained */ } + return new Date().toISOString(); +} + +function schemaEntryId(value) { + if (typeof value === 'string' && value.trim() !== '') return value; + return globalThis.crypto?.randomUUID + ? `entry-${globalThis.crypto.randomUUID()}` + : `entry-${Date.now()}-${Math.random()}`; +} + export function sanitizeEntry(input = {}) { const symptoms = {}; for (const key of URGENT_KEYS) symptoms[key] = input.symptoms?.[key] === true; - const bristolType = Math.min(7, Math.max(1, Number(input.bristolType) || 4)); - return { - id: String(input.id || globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`), - occurredAt: new Date(input.occurredAt || Date.now()).toISOString(), + const bristolType = toSchemaInteger(input.bristolType, { min: 1, max: 7, fallback: 4 }); + const provenanceOrigin = input.provenance && typeof input.provenance === 'object' + && typeof input.provenance.origin === 'string' + && KNOWN_PROVENANCE_ORIGINS.has(input.provenance.origin) + ? input.provenance.origin + : null; + const entry = { + id: schemaEntryId(input.id), + occurredAt: safeIsoTimestamp(input.occurredAt || Date.now()), bristolType, color: ['brown', 'green', 'yellow', 'pale', 'red', 'black'].includes(input.color) ? input.color : 'brown', - urgency: Math.min(4, Math.max(0, Number(input.urgency) || 0)), - discomfort: Math.min(4, Math.max(0, Number(input.discomfort) || 0)), - note: String(input.note || '').trim().slice(0, 500), - photoDataUrl: typeof input.photoDataUrl === 'string' && input.photoDataUrl.startsWith('data:image/') ? input.photoDataUrl : '', + urgency: toSchemaInteger(input.urgency, { min: 0, max: 4, fallback: 0 }), + discomfort: toSchemaInteger(input.discomfort, { min: 0, max: 4, fallback: 0 }), + note: typeof input.note === 'string' ? input.note.trim().slice(0, 500) : '', + photoDataUrl: canonicalRasterPhoto(input.photoDataUrl), symptoms, }; + if (provenanceOrigin) entry.provenance = { origin: provenanceOrigin }; + return entry; } export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 } = {}) { @@ -77,19 +186,176 @@ export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 } return 'Ready for your review. Choose the matching Bristol form yourself; Timmy does not interpret the picture.'; } +// Total portability budget: storage, exports, and imports are all bounded by +// the same 16 MiB of UTF-8 bytes, measured on the exact document that would be +// written or read. Enforcing the TOTAL (not just the inbound file) keeps every +// export re-importable and prevents doomed writes from ever being attempted. +export function estimateLedgerBytes(entries) { + return utf8ByteLength(JSON.stringify(Array.isArray(entries) ? entries : [])); +} + export function exportLedger(entries, exportedAt = new Date().toISOString()) { - return JSON.stringify({ - product: 'Timmy the Talking Turd', - schemaVersion: 1, + const text = JSON.stringify({ + product: PRODUCT_NAME, + schemaVersion: SCHEMA_VERSION, exportedAt, - entries: Array.isArray(entries) ? entries : [], + entries: Array.isArray(entries) ? entries.map(sanitizeEntry) : [], }, null, 2); + if (utf8ByteLength(text) > MAX_IMPORT_BYTES) { + throw new RangeError('This ledger has grown past the portable size limit. Remove some large photos, then export again.'); + } + return text; +} + +export function utf8ByteLength(text) { + return UTF8_ENCODER.encode(text).length; +} + +// Collision-safe deterministic merge with ID repair: +// - Every distinct existing (user-owned) record survives. Duplicate ids inside +// stored data are repaired, never dropped or silently merged: the first-seen +// row keeps its id and later twins get a deterministic `id#2`, `id#3`, … +// suffix, skipping suffixes already owned by real records. +// - Incoming rows are added only when their id is unused; intra-file duplicate +// ids keep exactly the first occurrence; every collision is reported back for +// explicit user feedback. Re-importing a file can therefore never duplicate, +// overwrite, or shadow user records. +// - Hostile id values (Symbol, BigInt, objects, missing) cannot throw: such +// records are repaired onto fresh deterministic string ids like "entry-3". +function repairLedgerIds(entries) { + const owned = new Set(); + const repaired = []; + for (const entry of entries) { + let id = typeof entry?.id === 'string' ? entry.id : ''; + if (!id || owned.has(id)) { + const base = id || 'entry'; + let n = 2; + while (owned.has(`${base}#${n}`)) n += 1; + id = `${base}#${n}`; + } + owned.add(id); + repaired.push(entry); + if (repaired[repaired.length - 1] !== entry || entry.id !== undefined) entry.id = id; + } + return repaired; +} + +export function mergeLedgers(existing, incoming) { + const local = repairLedgerIds( + Array.isArray(existing) + ? existing.filter(entry => entry && typeof entry === 'object' && !Array.isArray(entry)) + : [], + ); + for (const entry of local) entry.id = String(entry.id); + const seen = new Set(local.map(entry => entry.id)); + const merged = [...local]; + const added = []; + const skippedIds = []; + const source = Array.isArray(incoming) ? incoming : []; + for (const rawEntry of source) { + const rawId = rawEntry && typeof rawEntry === 'object' && !Array.isArray(rawEntry) + ? (typeof rawEntry.id === 'string' ? rawEntry.id : '') + : ''; + const entry = sanitizeEntry(rawEntry || {}); + if (!entry.id || seen.has(entry.id)) { + skippedIds.push(rawId || entry.id); + continue; + } + seen.add(entry.id); + merged.push(entry); + added.push(entry); + } + return { merged, added, skippedIds }; +} + +// Portability policy (explicit bound, symmetric by construction): +// - The app accepts photos up to 4 MiB of decoded binary (analysis.js +// MAX_IMAGE_BYTES); base64 encoding expands that to ~5.6 MiB and JSON adds a +// small envelope per entry, so any export this app can produce fits inside +// 16 MiB. +// - Imports are rejected past MAX_IMPORT_BYTES UTF-8 bytes before parsing user +// data. Every app-produced export therefore re-imports byte-symmetrically, +// while hostile or runaway files stay bounded. +export const MAX_IMPORT_BYTES = 16 * 1024 * 1024; +const PRODUCT_NAME = 'Timmy the Talking Turd'; +const SCHEMA_VERSION = 1; +const KNOWN_SCHEMA_VERSIONS = new Set([0, SCHEMA_VERSION]); + +// A bare top-level JSON array is accepted only when it matches the strict +// Timmy legacy ledger shape: at least one row, every row a plain object +// carrying a nonempty string id and an integer Bristol type (1–7). Arbitrary +// arrays of unrelated objects are rejected outright — import must never +// invent plausible medical defaults from JSON that was never a Timmy export. +function looksLikeLegacyTimmyArray(value) { + return Array.isArray(value) + && value.length > 0 + && value.every(row => row !== null && typeof row === 'object' && !Array.isArray(row) + && typeof row.id === 'string' && row.id.trim() !== '' + && Number.isInteger(row.bristolType) + && row.bristolType >= 1 && row.bristolType <= 7); } export function importLedger(text) { - const parsed = JSON.parse(text); - if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.entries)) throw new Error('This is not a supported Timmy export.'); - return parsed.entries.map(sanitizeEntry); + if (typeof text !== 'string' || text.length === 0) throw new Error('This is not a supported Timmy export.'); + if (utf8ByteLength(text) > MAX_IMPORT_BYTES) throw new RangeError('That file is too large to be a Timmy export.'); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + throw new Error('This is not a supported Timmy export.'); + } + if (Array.isArray(parsed)) { + if (!looksLikeLegacyTimmyArray(parsed)) throw new Error('This is not a supported Timmy export.'); + return parsed.map(sanitizeEntry); + } + const hasLedgerEnvelope = typeof parsed?.product === 'string' + && Number.isInteger(parsed?.schemaVersion) + && Array.isArray(parsed?.entries); + if (!hasLedgerEnvelope || parsed.product !== PRODUCT_NAME) { + throw new Error('This is not a supported Timmy export.'); + } + if (!KNOWN_SCHEMA_VERSIONS.has(parsed.schemaVersion)) { + throw new RangeError(`This export uses schema version ${parsed.schemaVersion} from a newer Timmy app. Update Timmy first, then import again.`); + } + const entries = parsed.entries.map(sanitizeEntry); + if (estimateLedgerBytes(entries) > MAX_IMPORT_BYTES) { + throw new RangeError('That file would expand past the portable size limit once migrated.'); + } + return entries; +} + +// Storage migration contract: whatever is in localStorage is validated and +// migrated BEFORE the UI renders, so malformed history can never blank the +// journal or crash boot. +// - Unparseable or wrong-shaped payloads collapse to an empty ledger instead +// of throwing; the caller learns via `changed` that a repairing rewrite is +// warranted. +// - Every distinct record survives: hostile fields are sanitized per entry, +// invalid dates become valid ISO timestamps, duplicate ids are repaired +// deterministically. Rows carrying neither an id nor a Bristol type are +// junk, never fabricated into records. +// - Healthy storage round-trips byte-for-byte and reports `changed: false`, +// so the app never rewrites what does not need repair. +export function migrateStoredLedger(raw) { + const empty = { entries: [], changed: false }; + if (typeof raw !== 'string' || raw.trim() === '') return empty; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { entries: [], changed: true }; + } + if (!Array.isArray(parsed)) return { entries: [], changed: true }; + const rows = parsed.filter(row => row !== null && typeof row === 'object' && !Array.isArray(row) + && ((typeof row.id === 'string' && row.id.trim() !== '') || Number.isInteger(row.bristolType))); + const entries = repairLedgerIds(rows.map(row => sanitizeEntry(row || {}))); + let changed; + try { + changed = rows.length !== parsed.length || JSON.stringify(entries) !== JSON.stringify(rows); + } catch { + changed = true; // hostile stored structures always warrant a repairing rewrite + } + return { entries, changed }; } export const urgentSymptomKeys = Object.freeze([...URGENT_KEYS]); diff --git a/tests/analysis.test.js b/tests/analysis.test.js index 56e130e..db99f70 100644 --- a/tests/analysis.test.js +++ b/tests/analysis.test.js @@ -57,7 +57,17 @@ test('rejects malformed model output instead of guessing defaults', () => { test('merges only visual fields and preserves user-reported context', () => { const form = { bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } }; const merged = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.8 }); - assert.deepEqual(merged, { bristolType: 4, color: 'brown', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } }); + assert.deepEqual(merged, { bristolType: 4, color: 'brown', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true }, provenance: { origin: 'ai-suggestion' } }); +}); + +test('records ai-suggestion provenance only when a suggestion is actually applied', () => { + const form = { bristolType: 2, color: 'green', urgency: 1, discomfort: 0, note: '', symptoms: {} }; + const abstained = mergeVisualSuggestion(form, { status: 'needs_user_input', reason: 'too uncertain' }); + assert.deepEqual(abstained, form); + assert.equal(abstained.provenance, undefined); + const suggested = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 5, color: 'yellow', confidence: 0.9 }); + assert.deepEqual(suggested.provenance, { origin: 'ai-suggestion' }); + assert.equal(suggested.urgency, form.urgency, 'nonvisual fields stay user-owned'); }); test('accepts bounded JPEG/PNG/WebP data URLs and rejects oversized or unsupported input', () => { diff --git a/tests/domain.test.js b/tests/domain.test.js index 08c43f3..22caaaa 100644 --- a/tests/domain.test.js +++ b/tests/domain.test.js @@ -8,6 +8,12 @@ import { detectUrgentText, exportLedger, hasUrgentLedgerContext, + importLedger, + estimateLedgerBytes, + MAX_IMPORT_BYTES, + mergeLedgers, + migrateStoredLedger, + utf8ByteLength, photoQualityMessage, sanitizeEntry, } from '../src/domain.js'; @@ -118,6 +124,40 @@ test('sanitizes a user entry to the MVP data contract', () => { assert.equal(entry.unexpected, undefined); }); +test('keeps only a bounded provenance origin and strips smuggled secrets', () => { + const entry = sanitizeEntry({ + id: 'prov-1', + bristolType: 4, + provenance: { origin: 'ai-suggestion', suggestedBristolType: 4, apiToken: 'sk-secret-value', sessionId: 'hermes-session-x' }, + }); + assert.deepEqual(entry.provenance, { origin: 'ai-suggestion' }); + assert.doesNotMatch(JSON.stringify(entry), /secret|session/i); +}); + +test('omits provenance entirely when none was recorded', () => { + const entry = sanitizeEntry({ id: 'plain-1', bristolType: 3 }); + assert.equal(entry.provenance, undefined); +}); + +test('rejects provenance origins outside the recorded vocabulary', () => { + for (const bogus of ['clinician', 'self-diagnosis', '']) { + const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: bogus } }); + assert.equal(entry.provenance, undefined, bogus); + } +}); + +test('provenance membership is own-property safe: inherited Object names are not origins', () => { + for (const poisoned of ['toString', 'constructor', '__proto__', 'hasOwnProperty', 'valueOf', 'isPrototypeOf']) { + const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: poisoned } }); + assert.equal(entry.provenance, undefined, poisoned); + assert.doesNotMatch(JSON.stringify(entry), new RegExp(poisoned), poisoned); + } + // Even a null-prototype provenance carrying a real origin stays acceptable. + const nullProto = sanitizeEntry({ id: 'y', bristolType: 4, provenance: Object.assign(Object.create(null), { origin: 'user' }) }); + assert.deepEqual(nullProto.provenance, { origin: 'user' }); +}); + + test('photo quality guidance is deterministic and does not claim visual diagnosis', () => { assert.match(photoQualityMessage({ width: 300, height: 300, brightness: 0.5 }), /closer/i); assert.match(photoQualityMessage({ width: 1200, height: 900, brightness: 0.02 }), /light/i); @@ -132,3 +172,569 @@ test('export ledger is portable JSON with version and entries', () => { assert.equal(parsed.exportedAt, '2026-08-18T00:00:00.000Z'); assert.equal(parsed.entries.length, 1); }); + +test('import migrates the legacy bare-array ledger to the current versioned envelope', () => { + const legacy = JSON.stringify([ + { id: 'legacy-1', occurredAt: '2026-08-17T12:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'older export' }, + { id: 'legacy-2', bristolType: 7 }, + ]); + const entries = importLedger(legacy); + assert.equal(entries.length, 2); + assert.equal(entries[0].id, 'legacy-1'); + assert.equal(entries[0].bristolType, 2); + assert.equal(entries[0].note, 'older export'); +}); + +test('import accepts every prior schema version and migrates entries forward', () => { + for (const version of [0, 1]) { + const payload = version === 0 + ? [{ id: `v${version}`, bristolType: 3 }] + : { product: 'Timmy the Talking Turd', schemaVersion: version, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: `v${version}`, bristolType: 3 }] }; + const entries = importLedger(JSON.stringify(payload)); + assert.equal(entries.length, 1, `schemaVersion ${version}`); + assert.equal(entries[0].bristolType, 3, `schemaVersion ${version}`); + } +}); + +test('import fails safely on a newer schema version instead of guessing', () => { + for (const schemaVersion of [2, 99]) { + assert.throws( + () => importLedger(JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion, entries: [{ id: 'x' }] })), + error => error instanceof RangeError && /newer Timmy app/i.test(error.message), + `schemaVersion ${schemaVersion}`, + ); + } +}); + +test('import fails safely on malformed or wrong-shaped payloads', () => { + for (const payload of [ + 'not json', + '{"schemaVersion":1,"entries":{}}', + '{"entries":[]}', + '{"product":"Other App","schemaVersion":1,"entries":[]}', + null, + 42, + ]) { + assert.throws(() => importLedger(payload), /not a supported Timmy export/, JSON.stringify(String(payload)).slice(0, 40)); + } +}); + +test('import rejects oversized ledgers before parsing user data', () => { + const huge = JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 1, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: 'x', note: 'n'.repeat(MAX_IMPORT_BYTES + 1024) }] }); + assert.ok(huge.length > MAX_IMPORT_BYTES); + assert.throws(() => importLedger(huge), RangeError); +}); + +test('utf8ByteLength measures UTF-8 bytes, not UTF-16 code units', () => { + assert.equal(utf8ByteLength(''), 0); + assert.equal(utf8ByteLength('abc'), 3); + // é is 1 UTF-16 unit but 2 UTF-8 bytes; 💩 is 2 UTF-16 units but 4 UTF-8 bytes. + assert.equal(utf8ByteLength('é'), 2); + assert.equal(utf8ByteLength('💩'), 4); + assert.equal(utf8ByteLength('aé💩b'), 1 + 2 + 4 + 1); + const emojiBlob = '💩'.repeat(1000); + assert.equal(emojiBlob.length, 2000, 'sanity: two code units each'); + assert.equal(utf8ByteLength(emojiBlob), 4000); +}); + +test('import rejects oversized payloads by UTF-8 bytes regardless of composition', () => { + const asciiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + 'n'.repeat(MAX_IMPORT_BYTES) + '"}]}'; + assert.ok(utf8ByteLength(asciiOver) > MAX_IMPORT_BYTES); + assert.throws(() => importLedger(asciiOver), RangeError); + // 4 bytes per glyph: byte size crosses the cap at half the code-unit count. + const emojiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + '💩'.repeat(Math.ceil(MAX_IMPORT_BYTES / 2)) + '"}]}'; + assert.ok(emojiOver.length <= MAX_IMPORT_BYTES * 1.01, 'code-unit count must not be what trips this'); + assert.ok(utf8ByteLength(emojiOver) > MAX_IMPORT_BYTES); + assert.throws(() => importLedger(emojiOver), RangeError); +}); + +test('import accepts a dense multibyte payload just under the byte cap', () => { + // Many small multibyte entries packed deterministically so that both the raw + // file and the fully migrated ledger stay inside the total portability + // budget (migration expands each row to the strict current schema). + const head = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":['; + const makeEntry = i => ({ id: `m${i}`, occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'café ☕', photoDataUrl: '', symptoms: {} }); + const sampleMigrated = JSON.stringify(sanitizeEntry(makeEntry(0))); + const perRowRaw = utf8ByteLength(JSON.stringify(makeEntry(0))) + 1; // + comma + const perRowMigrated = utf8ByteLength(sampleMigrated) + 1; + // Fixed-width ids keep every row the same size, so packing is exact. The + // fill factor keeps BOTH documents inside the budget: the migrated ledger + // near the cap, the raw file above half of it. + const budget = Math.floor((MAX_IMPORT_BYTES * 0.9 - utf8ByteLength(head) - 2) / perRowMigrated); + const count = Math.max(1, budget); + const payload = `${head}${Array.from({ length: count }, (_, i) => JSON.stringify({ ...makeEntry(i), id: `m${String(i).padStart(8, '0')}` })).join(',')}]}`; + assert.ok(utf8ByteLength(payload) <= MAX_IMPORT_BYTES); + assert.ok(utf8ByteLength(payload) > MAX_IMPORT_BYTES * 0.5, 'payload must carry real multibyte mass'); + const imported = importLedger(payload); + assert.equal(imported.length, count, 'every packed entry survives'); + assert.ok(estimateLedgerBytes(imported) <= MAX_IMPORT_BYTES, 'migrated total must respect the portability budget'); + assert.equal(imported[imported.length - 1].note, 'café ☕'); +}); + +test('a maximal app-produced export with a 4 MiB photo round trips byte-symmetrically', () => { + // 4 MiB binary is the app-wide photo ceiling (analysis.js MAX_IMAGE_BYTES). + const photoBytes = 4 * 1024 * 1024; + let b64 = Buffer.from('a'.repeat(photoBytes)).toString('base64'); + const entry = sanitizeEntry({ + id: 'big-photo', + bristolType: 4, + photoDataUrl: `data:image/jpeg;base64,${b64}`, + note: 'boundary photo', + }); + const exported = exportLedger([entry], '2026-08-22T00:00:00.000Z'); + assert.ok(utf8ByteLength(exported) <= MAX_IMPORT_BYTES, 'largest producible export must stay inside the import cap'); + const roundTripped = importLedger(exported); + assert.equal(roundTripped.length, 1); + assert.equal(roundTripped[0].photoDataUrl, entry.photoDataUrl, 'photo survives the round trip without silent loss'); +}); + +test('import rejects payloads past the explicit portability ceiling before parsing', () => { + const huge = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + 'n'.repeat(MAX_IMPORT_BYTES + 1024) + '"}]}'; + assert.ok(utf8ByteLength(huge) > MAX_IMPORT_BYTES); + assert.throws(() => importLedger(huge), RangeError); +}); + +test('merge keeps every distinct record and never duplicates or overwrites user-owned entries', () => { + const local = [ + { id: 'a', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, note: 'local version of shared id' }, + { id: 'b', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'local b', photoDataUrl: '', symptoms: {} }, + ]; + const incoming = importLedger(JSON.stringify([ + { id: 'b', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, color: 'black', urgency: 4, note: 'hostile rewrite of existing id' }, + { id: 'c', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 6, note: 'new from file' }, + ])); + const result = mergeLedgers(local, incoming); + assert.equal(result.merged.length, 3, 'one row per unique id'); + assert.equal(result.merged.filter(entry => entry.id === 'b').length, 1, 'no duplicate ids'); + assert.equal(result.merged.find(entry => entry.id === 'b').note, 'local b', 'existing user-owned entry is never overwritten'); + assert.deepEqual(result.added.map(entry => entry.id), ['c'], 'only genuinely new records are added'); + assert.deepEqual(result.skippedIds, ['b'], 'collisions are reported explicitly'); + // Order stays deterministic: local rows first in their stored order, then additions in incoming order. + assert.deepEqual(result.merged.map(entry => entry.id), ['a', 'b', 'c']); +}); + +test('re-importing the same file twice changes nothing (idempotent)', () => { + const base = [{ id: 'seed', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2 }]; + const file = importLedger(JSON.stringify([{ id: 'seed', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2 }, { id: 'extra', occurredAt: '2026-08-22T08:30:00.000Z', bristolType: 3 }])); + const first = mergeLedgers(base, file); + assert.deepEqual(first.added.map(entry => entry.id), ['extra']); + const second = mergeLedgers(first.merged, file); + assert.equal(second.merged.length, first.merged.length, 'second import adds nothing'); + assert.deepEqual(second.added, [], 'second import reports no additions'); + assert.deepEqual(second.skippedIds.sort(), ['extra', 'seed'], 'both already-present ids are reported as skipped'); +}); + +test('duplicate ids inside stored data are repaired deterministically, never dropped or silently merged', () => { + // Two genuinely different local records that share one id (legacy storage + // corruption or a double-save bug) must BOTH survive with distinct stable ids. + const local = [ + { id: 'twins', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'first twin', photoDataUrl: '', symptoms: {} }, + { id: 'twins', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 6, color: 'yellow', urgency: 1, discomfort: 0, note: 'second twin', photoDataUrl: '', symptoms: {} }, + ]; + const incoming = importLedger(JSON.stringify([{ id: 'fresh', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 4 }])); + const result = mergeLedgers(local, incoming); + assert.equal(result.merged.length, 3, 'every distinct record survives a duplicate-id collision'); + const ids = result.merged.map(entry => entry.id); + assert.equal(new Set(ids).size, 3, 'no duplicate ids remain after repair'); + assert.equal(result.merged[0].id, 'twins', 'first-seen record keeps its original id'); + const secondTwin = result.merged.find(entry => entry.note === 'second twin'); + assert.ok(secondTwin, 'second twin still present'); + assert.equal(secondTwin.id, 'twins#2', 'derived id is deterministic, not random'); + assert.equal(secondTwin.bristolType, 6, 'repaired record keeps its own data'); + assert.deepEqual(result.added.map(entry => entry.id), ['fresh'], 'import still reports additions normally'); + // Re-merging the repaired ledger is idempotent: nothing changes, nothing new. + const second = mergeLedgers(result.merged, incoming); + assert.equal(second.merged.length, 3, 'repaired ledger re-merges without growth'); + assert.deepEqual(second.added, []); +}); + +test('repair ids never collide with real records: chained suffixes are skipped', () => { + const local = [ + { id: 'twins', bristolType: 2, note: 'a' }, + { id: 'twins#2', bristolType: 3, note: 'real record that owns the derived slot' }, + { id: 'twins', bristolType: 4, note: 'b' }, + ]; + const result = mergeLedgers(local, []); + assert.equal(result.merged.length, 3, 'all three records survive'); + const ids = result.merged.map(entry => entry.id); + assert.equal(new Set(ids).size, 3, 'derived id must not steal the real record\'s id'); + assert.equal(result.merged.find(entry => entry.note === 'real record that owns the derived slot').id, 'twins#2'); + assert.equal(result.merged.find(entry => entry.note === 'b').id, 'twins#3', 'duplicate scans past every owned suffix'); +}); + +test('duplicate ids inside one imported file keep exactly the first occurrence', () => { + const incoming = importLedger(JSON.stringify([ + { id: 'dupe', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, note: 'first in file' }, + { id: 'dupe', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, note: 'hostile later rewrite' }, + ])); + const result = mergeLedgers([], incoming); + assert.equal(result.merged.length, 1, 'one row per id, first occurrence wins deterministically'); + assert.equal(result.merged[0].note, 'first in file'); + assert.deepEqual(result.skippedIds, ['dupe'], 'later duplicates are reported, not silently dropped'); +}); + +test('local duplicate ids still win over an incoming file that reuses their id', () => { + const local = [ + { id: 'twin', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, note: 'local twin one', photoDataUrl: '', symptoms: {} }, + { id: 'twin', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 3, note: 'local twin two', photoDataUrl: '', symptoms: {} }, + ]; + const incoming = importLedger(JSON.stringify([ + { id: 'twin', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, note: 'hostile import' }, + ])); + const result = mergeLedgers(local, incoming); + assert.equal(result.merged.length, 2, 'both local records survive; hostile import adds nothing'); + assert.ok(result.merged.every(entry => entry.note.startsWith('local twin')), 'import never shadows user-owned records'); + assert.deepEqual(result.added, []); + assert.deepEqual(result.skippedIds, ['twin']); +}); + +test('merge tolerates hostile id types without throwing', () => { + const hostile = [ + { id: Symbol('sym'), bristolType: 2, note: 'symbol id' }, + { id: 123n, bristolType: 3, note: 'bigint id' }, + { bristolType: 4, note: 'no id at all' }, + ]; + let result; + assert.doesNotThrow(() => { result = mergeLedgers(hostile, []); }); + assert.equal(result.merged.length, 3, 'records with unusable ids are repaired, not dropped'); + const ids = result.merged.map(entry => entry.id); + assert.equal(new Set(ids).size, 3); + assert.ok(ids.every(id => typeof id === 'string' && id !== ''), 'every repaired id is a nonempty string'); +}); + +test('bare top-level arrays are accepted only as a strict Timmy legacy ledger', () => { + const legacy = [ + { id: 'l1', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, note: '' }, + { id: 'l2', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 2, color: 'green' }, + ]; + assert.equal(importLedger(JSON.stringify(legacy)).length, 2, 'genuine legacy exports stay importable'); +}); + +test('ambiguous arrays of unrelated objects are rejected, never defaulted into medical records', () => { + for (const payload of [ + [{ userId: 7, email: 'person@example.com', preferences: { theme: 'dark' } }], + [{ name: 'Alice', role: 'admin' }, { name: 'Bob', role: 'user' }], + [{ sku: 'X1', quantity: 3 }], + [], + [42], + ['2026-08-22T09:00:00.000Z'], + [null], + ]) { + let entries; + assert.throws( + () => { entries = importLedger(JSON.stringify(payload)); }, + /not a supported Timmy export/, + `must reject: ${JSON.stringify(payload).slice(0, 60)}`, + ); + assert.equal(entries, undefined, 'no invented defaults may leak from rejected payloads'); + } +}); + +test('legacy array rows need a nonempty string id and a Bristol type; one bad row rejects the batch', () => { + assert.throws(() => importLedger(JSON.stringify([{ id: 'x' }])), /not a supported Timmy export/, 'missing bristolType'); + assert.throws(() => importLedger(JSON.stringify([{ bristolType: 4 }])), /not a supported Timmy export/, 'missing id'); + assert.throws(() => importLedger(JSON.stringify([{ id: '', bristolType: 4 }])), /not a supported Timmy export/, 'empty id'); + const mixed = JSON.stringify([{ id: 'ok-row', bristolType: 3 }, { foo: 1 }]); + assert.throws(() => importLedger(mixed), /not a supported Timmy export/, 'partial acceptance would invent data'); +}); + +test('photos validate canonically: strict grammar, base64 round-trip, decoded size floor, and magic bytes', () => { + const jpegBytes = Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]), Buffer.alloc(600, 0x33)]); + const pngBytes = Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), Buffer.alloc(600, 0x44)]); + const webpBytes = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x60, 0x02, 0x00, 0x00]), Buffer.from('WEBPVP8 '), Buffer.alloc(500, 0x55)]); + const url = bytes => `base64:${bytes.toString('base64')}`; + const okJpeg = `data:image/jpeg;base64,${jpegBytes.toString('base64')}`; + const okPng = `data:image/png;base64,${pngBytes.toString('base64')}`; + const okWebp = `data:image/webp;base64,${webpBytes.toString('base64')}`; + assert.equal(sanitizeEntry({ id: 'p1', photoDataUrl: okJpeg }).photoDataUrl, okJpeg); + assert.equal(sanitizeEntry({ id: 'p2', photoDataUrl: okPng }).photoDataUrl, okPng); + assert.equal(sanitizeEntry({ id: 'p3', photoDataUrl: okWebp }).photoDataUrl, okWebp); + // Mislabeled content is rejected even with perfect base64 grammar: the + // decoded bytes must carry the declared format's real magic signature. + const svgPayload = Buffer.from(''); + const svgAsJpeg = `data:image/jpeg;base64,${svgPayload.toString('base64')}`; + const htmlAsPng = `data:image/png;base64,${Buffer.from('hi').toString('base64')}`; + const pngBytesAsJpeg = `data:image/jpeg;base64,${pngBytes.toString('base64')}`; + for (const bad of [ + svgAsJpeg, + htmlAsPng, + pngBytesAsJpeg, + 'data:image/svg+xml;base64,PHN2Zy8+', + 'data:image/gif;base64,R0lGODlh', + // Grammar violations: whitespace/newlines, excess padding, base64url alphabet. + `data:image/jpeg;base64,${jpegBytes.toString('base64').replace(/(.{20})/, '$1\n')}`, + 'data:image/jpeg;base64,aGVsbG8===', + `data:image/jpeg;base64,${jpegBytes.toString('base64').replace(/A/g, '_')}`, + 'data:image/jpeg;base64,%2Dencoded', + // Noncanonical tiny junk: grammatically valid but far below any real image. + `data:image/jpeg;base64,${Buffer.from('ok').toString('base64')}`, + `data:image/png;base64,${Buffer.alloc(31, 0x89).toString('base64')}`, + 'http://example.com/photo.jpg', + 42, + undefined, + ]) { + const label = typeof bad === 'string' ? bad.slice(0, 44) : String((bad && bad.constructor && bad.constructor.name) || 'value'); + assert.equal(sanitizeEntry({ id: 'bad', photoDataUrl: bad }).photoDataUrl, '', `rejected: ${label}`); + } + // Decoded-binary ceiling mirrors the app-wide 4 MiB photo limit. + const overDecoded = `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.alloc(4 * 1024 * 1024 - 3, 0x77)]).toString('base64')}`; + assert.ok(Buffer.from(overDecoded.slice(23), 'base64').length > 4 * 1024 * 1024); + assert.equal(sanitizeEntry({ id: 'huge', photoDataUrl: overDecoded }).photoDataUrl, '', 'decoded payload past the 4 MiB app ceiling is rejected'); +}); + +test('sanitizeEntry absorbs Symbol, BigInt, and hostile date values without throwing', () => { + const hostile = { + id: Symbol('sym'), + occurredAt: { toString() { throw new Error('date toString boom'); } }, + bristolType: 4n, + color: Symbol('green'), + urgency: BigInt(3), + discomfort: { valueOf() { throw new Error('valueOf boom'); } }, + note: Object.create(Object.prototype, { toString: { value() { throw new Error('note boom'); } } }), + photoDataUrl: Symbol('photo'), + symptoms: null, + provenance: 'user', + extraSymbolKey: Symbol('ignored'), + }; + hostile[Symbol('poison')] = 'never'; + let entry; + assert.doesNotThrow(() => { entry = sanitizeEntry(hostile); }); + assert.equal(typeof entry.id, 'string'); + assert.match(entry.id, /^entry-/); + assert.equal(Number.isNaN(new Date(entry.occurredAt).getTime()), false, 'hostile date becomes a safe ISO timestamp'); + assert.equal(entry.bristolType, 4, 'BigInt Bristol type falls back to neutral default'); + assert.equal(entry.color, 'brown', 'Symbol color falls back to the default'); + assert.equal(entry.urgency, 0, 'BigInt urgency is not a schema integer'); + assert.equal(entry.discomfort, 0); + assert.equal(entry.note, '', 'non-coercible notes become empty instead of crashing'); + assert.equal(entry.photoDataUrl, ''); + assert.deepEqual(entry.symptoms, { blood: false, blackOrDarkRed: false, severePain: false, vomiting: false, fever: false, cannotPassGas: false }); + assert.doesNotThrow(() => JSON.stringify(entry), 'result must stay serializable'); + const poisonedDate = new Date('2026-08-01T00:00:00.000Z'); + Object.defineProperty(poisonedDate, 'getTime', { value() { throw new Error('getTime boom'); } }); + let survived; + assert.doesNotThrow(() => { survived = sanitizeEntry({ id: 'pd', occurredAt: poisonedDate }); }); + assert.equal(Number.isNaN(new Date(survived.occurredAt).getTime()), false); + let symbolDate; + assert.doesNotThrow(() => { symbolDate = sanitizeEntry({ id: 'sd', occurredAt: Symbol('nope') }); }); + assert.match(symbolDate.occurredAt, /^\d{4}-\d{2}-\d{2}T/); +}); + +test('stored localStorage payloads are validated and migrated before any render', () => { + // Malformed real-world storage: an invalid date that would blank the UI, + // a duplicate id, and one garbage row that is not an entry at all. + const stored = JSON.stringify([ + { id: 'keep-1', occurredAt: 'not-a-real-date', bristolType: 3, color: 'brown', urgency: 2, discomfort: 1, note: 'broken date', photoDataUrl: '', symptoms: {} }, + { id: 'keep-2', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'healthy row', photoDataUrl: '', symptoms: {} }, + { id: 'keep-2', occurredAt: '2026-08-22T10:15:00.000Z', bristolType: 6, color: 'yellow', urgency: 1, discomfort: 0, note: 'twin row', photoDataUrl: '', symptoms: {} }, + { completely: 'not an entry' }, + 'a bare string row', + 17, + ]); + let migrated; + assert.doesNotThrow(() => { migrated = migrateStoredLedger(stored); }); + assert.equal(migrated.entries.length, 3, 'every distinct record survives; junk rows are dropped, never fabricated'); + const ids = migrated.entries.map(entry => entry.id); + assert.equal(new Set(ids).size, 3, 'duplicate stored ids are repaired'); + assert.equal(Number.isNaN(new Date(migrated.entries.find(entry => entry.note === 'broken date').occurredAt).getTime()), false, + 'invalid dates become valid ISO timestamps so the UI can never blank out'); + assert.equal(migrated.entries.find(entry => entry.note === 'twin row').id, 'keep-2#2'); + assert.equal(typeof migrated.changed, 'boolean'); +}); + +test('valid stored ledgers pass through migration unchanged', () => { + const healthy = [ + sanitizeEntry({ id: 'h1', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4 }), + sanitizeEntry({ id: 'h2', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 2, provenance: { origin: 'user' } }), + ]; + const result = migrateStoredLedger(JSON.stringify(healthy)); + assert.deepEqual(result.entries, healthy, 'healthy storage is byte-for-byte stable through migration'); + assert.equal(result.changed, false, 'no rewrite is flagged when nothing needed repair'); +}); + +test('corrupt or wrong-shaped stored values migrate to an empty ledger instead of crashing boot', () => { + for (const corrupt of ['', ' ', '{not json', 'null', '"just a string"', '{"entries":[]}', '[]', 'undefined']) { + let result; + assert.doesNotThrow(() => { result = migrateStoredLedger(corrupt); }, `corrupt value: ${corrupt.slice(0, 24)}`); + assert.deepEqual(result.entries, [], `empty ledger for corrupt value: ${corrupt.slice(0, 24)}`); + if (result.changed !== undefined) assert.equal(typeof result.changed, 'boolean'); + } +}); + +test('hostile stored values cannot crash the migration pass', () => { + const raw = JSON.stringify([{ id: 'x', occurredAt: '2026-08-01T09:00:00.000Z', bristolType: 4 }, { id: 'y', occurredAt: { evil: true }, bristolType: { deep: [1, 2] } }]); + let result; + assert.doesNotThrow(() => { result = migrateStoredLedger(raw); }); + assert.equal(result.entries.length, 2); + assert.equal(Number.isNaN(new Date(result.entries[0].occurredAt).getTime()), false); + // Circular structures and hostile toJSON must also stay contained. + const circular = {}; + circular.self = circular; + assert.doesNotThrow(() => migrateStoredLedger(circular)); + const hostileToJson = [{ toJSON() { throw new Error('toJSON boom'); } }]; + assert.doesNotThrow(() => migrateStoredLedger(hostileToJson)); +}); + +test('ledger size is measured as the exact UTF-8 bytes storage will hold', () => { + const entries = [sanitizeEntry({ id: 'e1', bristolType: 4, note: 'café ☕' })]; + assert.equal(estimateLedgerBytes(entries), utf8ByteLength(JSON.stringify(entries))); + assert.equal(estimateLedgerBytes([]), utf8ByteLength('[]')); + const withPhoto = [sanitizeEntry({ id: 'e2', bristolType: 2, photoDataUrl: `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF]), Buffer.alloc(2048, 0x44)]).toString('base64')}` })]; + assert.equal(estimateLedgerBytes(withPhoto), utf8ByteLength(JSON.stringify(withPhoto))); +}); + +test('exports past the total portability budget are refused instead of producing non-importable files', () => { + const bigPhoto = `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.alloc(4 * 1024 * 1024 - 4, 0x66)]).toString('base64')}`; + const entries = [1, 2, 3, 4].map(n => sanitizeEntry({ id: `big-${n}`, bristolType: 4, photoDataUrl: bigPhoto })); + let text; + assert.throws(() => { text = exportLedger(entries, '2026-08-22T00:00:00.000Z'); }, RangeError, + 'an export that could never re-import must not be produced'); + assert.equal(text, undefined, 'no oversized document may leak from a refused export'); + // A single maximal photo entry still exports fine. + assert.doesNotThrow(() => exportLedger([entries[0]], '2026-08-22T00:00:00.000Z')); +}); + +test('imports whose migrated total would exceed the portability budget are rejected before the caller can mutate', () => { + // Raw bytes stay just under the cap; the migrated ledger (every row expanded + // to the full current schema with explicit symptom fields) crosses it, so + // import must refuse up front instead of letting a doomed write happen. + const head = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":['; + const makeRawEntry = i => `{"id":"x${String(i).padStart(7, '0')}","bristolType":4,"occurredAt":"x"}`; + // Solve the row count directly with uniform-width ids: raw bytes sit just + // under the cap while the migrated expansion crosses it. + const rowLen = utf8ByteLength(makeRawEntry(0)); + const available = MAX_IMPORT_BYTES - 4096 - utf8ByteLength(head) - 3; // "],}" tail + const count = Math.max(1, Math.floor((available + 1) / (rowLen + 1))); // + comma per row + const raw = `${head}${Array.from({ length: count }, (_, i) => makeRawEntry(i)).join(',')}]}` + + ''; + assert.ok(utf8ByteLength(raw) <= MAX_IMPORT_BYTES, 'fixture raw bytes must stay inside the cap'); + assert.ok(utf8ByteLength(raw) > MAX_IMPORT_BYTES * 0.9, 'fixture must sit close to the raw boundary'); + let entries; + assert.throws(() => { entries = importLedger(raw); }, error => error instanceof RangeError && /expand past/.test(error.message), + 'migrated total past the budget must refuse before returning entries'); + assert.equal(entries, undefined, 'no entries may leak from a refused import'); +}); + +test('merge sanitizes incoming entries so imports cannot smuggle hostile fields into storage', () => { + const incoming = [{ id: 'proto-entry', bristolType: 4, __proto__: { poisoned: true }, extra: 'strip me' }, { id: 'ctor-entry', bristolType: 4, sessionCookie: 'SID=x' }]; + const result = mergeLedgers([], incoming); + assert.equal(result.merged.length, 2, 'both records still import as data'); + for (const entry of result.merged) { + assert.equal(Object.getPrototypeOf(entry), Object.prototype, `plain-object entry ${entry.id}`); + assert.equal(entry.poisoned, undefined, 'prototype payload must not leak'); + assert.equal(entry.extra, undefined, 'unknown fields stay out of storage'); + assert.equal(entry.sessionCookie, undefined, 'smuggled secrets stay out of storage'); + } + const serialized = JSON.stringify(result.merged); + assert.doesNotMatch(serialized, /poisoned|extra|sessionCookie|SID=/); +}); + +test('current-schema numeric fields are strict integers within clinical bounds', () => { + for (const bogus of [4.5, '3', 0, 8, NaN, null, true, [3]]) { + const entry = sanitizeEntry({ id: 'n', bristolType: bogus }); + assert.equal(entry.bristolType, 4, `bristolType ${JSON.stringify(String(bogus))} falls back to the neutral default`); + } + assert.equal(sanitizeEntry({ id: 'ok1', bristolType: 1 }).bristolType, 1); + assert.equal(sanitizeEntry({ id: 'ok2', bristolType: 7 }).bristolType, 7); + const mixed = sanitizeEntry({ id: 'm', urgency: 2.5, discomfort: -1 }); + assert.equal(mixed.urgency, 0); + assert.equal(mixed.discomfort, 0); + const strings = sanitizeEntry({ id: 's', urgency: '3', discomfort: 11 }); + assert.equal(strings.urgency, 0, 'numeric strings are not schema integers'); + assert.equal(strings.discomfort, 0); + const valid = sanitizeEntry({ id: 'v', urgency: 3, discomfort: 4 }); + assert.equal(valid.urgency, 3); + assert.equal(valid.discomfort, 4); +}); + +test('photo fields accept only approved raster JPEG/PNG/WebP base64 data URLs', () => { + // Canonical, magic-valid samples of every supported format (large enough to + // satisfy the canonical photo contract's decoded-size floor). + const okJpeg = `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF]), Buffer.alloc(120, 0x11)]).toString('base64')}`; + const okPng = `data:image/png;base64,${Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), Buffer.alloc(120, 0x22)]).toString('base64')}`; + const okWebp = `data:image/webp;base64,${Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x70, 0x00, 0x00, 0x00]), Buffer.from('WEBPVP8 '), Buffer.alloc(110, 0x33)]).toString('base64')}`; + assert.equal(sanitizeEntry({ id: 'p1', photoDataUrl: okJpeg }).photoDataUrl, okJpeg); + assert.equal(sanitizeEntry({ id: 'p2', photoDataUrl: okPng }).photoDataUrl, okPng); + assert.equal(sanitizeEntry({ id: 'p3', photoDataUrl: okWebp }).photoDataUrl, okWebp); + for (const bad of [ + 'data:image/svg+xml;base64,PHN2Zy8+', + 'data:image/svg+xml,', + 'data:image/gif;base64,R0lGODlh', + 'data:image/jpeg;base64,!!!not-base64!!!', + 'data:image/jpeg,percent%2Dencoded', + 'data:text/html;base64,PGh0bWw+', + 'http://example.com/photo.jpg', + 42, + // Grammar-valid but non-raster or undersized payloads are dropped too: + // the canonical contract rejects tiny junk that merely parses as base64. + 'data:image/jpeg;base64,b2s=', + `data:image/jpeg;base64,${Buffer.from('plain text, not an image').toString('base64')}`, + ]) { + assert.equal(sanitizeEntry({ id: 'bad', photoDataUrl: bad }).photoDataUrl, '', `rejected: ${String(bad).slice(0, 40)}`); + } +}); + +test('invalid or missing dates never throw and never persist Invalid Date values', () => { + for (const bad of ['not-a-date', '2026-13-45T99:99:99Z', {}, ['2026-01-01'], true]) { + let entry; + assert.doesNotThrow(() => { entry = sanitizeEntry({ id: 'd', occurredAt: bad }); }, String(bad)); + assert.match(entry.occurredAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + assert.equal(Number.isNaN(new Date(entry.occurredAt).getTime()), false, `safe ISO for ${String(bad)}`); + } + const blank = sanitizeEntry({ id: 'd2', occurredAt: '' }); + assert.equal(Number.isNaN(new Date(blank.occurredAt).getTime()), false); + const kept = sanitizeEntry({ id: 'd3', occurredAt: '2026-08-01T10:00:00.000Z' }); + assert.equal(kept.occurredAt, '2026-08-01T10:00:00.000Z', 'valid dates pass through unchanged'); +}); + +test('imports containing invalid dates migrate forward instead of crashing the whole ledger', () => { + const payload = JSON.stringify([ + { id: 'bad-date', occurredAt: 'garbage-date-value', bristolType: 3 }, + { id: 'good-date', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4 }, + ]); + const imported = importLedger(payload); + assert.equal(imported.length, 2, 'one bad field cannot destroy the batch'); + assert.equal(Number.isNaN(new Date(imported[0].occurredAt).getTime()), false, 'bad date becomes a safe ISO timestamp'); + assert.equal(imported[1].occurredAt, '2026-08-20T09:00:00.000Z'); +}); + +test('round trip preserves confirmed values and provenance without leaking secrets', () => { + const saved = [ + sanitizeEntry({ + id: 'r1', + occurredAt: '2026-08-19T08:30:00.000Z', + bristolType: 2, + color: 'green', + urgency: 3, + discomfort: 2, + note: 'rough morning', + provenance: { origin: 'ai-suggestion', apiToken: 'sk-leaked-token' }, + }), + { + id: 'raw-2', bristolType: 9, color: 'chartreuse', urgency: 11, discomfort: -4, + note: 'odd shape', symptoms: { blood: true }, sessionCookie: 'SID=hijack', + }, + ]; + const exported = exportLedger(saved, '2026-08-20T00:00:00.000Z'); + assert.doesNotMatch(exported, /sk-leaked-token|SID=hijack|chartreuse/); + const roundTripped = importLedger(exported); + assert.equal(roundTripped.length, 2); + assert.deepEqual( + { id: roundTripped[0].id, occurredAt: roundTripped[0].occurredAt, bristolType: roundTripped[0].bristolType, color: roundTripped[0].color, urgency: roundTripped[0].urgency, discomfort: roundTripped[0].discomfort, note: roundTripped[0].note }, + { id: 'r1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'rough morning' }, + ); + assert.equal(roundTripped[0].symptoms.blood, false); + assert.deepEqual(roundTripped[0].provenance, { origin: 'ai-suggestion' }); + assert.equal(roundTripped[1].bristolType, 4, 'out-of-range Bristol type falls back to the neutral default under strict schema'); + assert.equal(roundTripped[1].color, 'brown'); + assert.equal(roundTripped[1].urgency, 0, 'out-of-range urgency falls back to the neutral default'); + assert.equal(roundTripped[1].discomfort, 0); + assert.deepEqual(roundTripped[1].provenance, undefined); +}); + +test('re-exporting an imported ledger converges to the same portable document', () => { + const entries = [{ id: 'c1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 6, color: 'yellow', urgency: 2, discomfort: 1, note: 'loose', provenance: { origin: 'user' } }]; + const first = JSON.parse(exportLedger(entries, '2026-08-20T00:00:00.000Z')); + const second = JSON.parse(exportLedger(importLedger(exportLedger(entries, '2026-08-20T00:00:00.000Z')), '2026-08-20T00:00:00.000Z')); + assert.deepEqual(second, first); +}); diff --git a/tests/ledger-portability.acceptance.mjs b/tests/ledger-portability.acceptance.mjs new file mode 100644 index 0000000..28cd8b3 --- /dev/null +++ b/tests/ledger-portability.acceptance.mjs @@ -0,0 +1,316 @@ +import { chromium } from 'playwright'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; + +const ROOT_STORE = 'timmy:/:ledger-v1'; +const LEGACY_STORE = 'timmy-ledger-v1'; + +await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => { + const browser = await chromium.launch({ headless: true }); + try { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, serviceWorkers: 'block' }); + const page = await context.newPage(); + const errors = []; + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + page.on('pageerror', error => errors.push(error.message)); + page.on('dialog', dialog => dialog.accept()); + await page.goto(process.env.TIMMY_TEST_URL || 'http://127.0.0.1:4173', { waitUntil: 'networkidle' }); + await page.evaluate(() => localStorage.clear()); + await page.reload({ waitUntil: 'networkidle' }); + + // Seed one confirmed, user-owned entry with provenance. + await page.evaluate(() => { + localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([ + { + id: 'seed-1', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2, color: 'green', + urgency: 3, discomfort: 2, note: 'seeded confirmed entry', photoDataUrl: '', + symptoms: { blood: false }, provenance: { origin: 'ai-suggestion' }, + }, + ])); + }); + await page.reload({ waitUntil: 'networkidle' }); + await page.locator('[data-view="calendar"]').last().click(); + assert.equal(await page.locator('.entry').count(), 1, 'seeded entry renders'); + + // Export writes the versioned envelope with provenance and no secrets. + await page.locator('[data-view="calendar"]').last().click(); + await page.locator('[data-view="privacy"]').click(); + const downloadPromise = page.waitForEvent('download'); + await page.locator('#export').click(); + const download = await downloadPromise; + const exportedText = await download.path().then(readFile).then(buffer => buffer.toString('utf8')); + const exported = JSON.parse(exportedText); + assert.equal(exported.schemaVersion, 1); + assert.equal(exported.entries[0].provenance.origin, 'ai-suggestion'); + assert.doesNotMatch(exportedText, /apiToken|sessionId|sk-/i); + + // Importing a prior-version export merges instead of replacing user data. + const legacyPath = join(workDir, 'legacy-ledger.json'); + await writeFile(legacyPath, JSON.stringify([ + { id: 'legacy-9', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'legacy import' }, + ])); + await page.setInputFiles('#import', legacyPath); + await page.getByText('Ledger imported').waitFor({ timeout: 5000 }); + const mergedIds = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); + assert.deepEqual(mergedIds.sort(), ['legacy-9', 'seed-1'], 'import must merge, not replace'); + + // A future schema version fails safely and leaves the ledger untouched. + const futurePath = join(workDir, 'future-ledger.json'); + await writeFile(futurePath, JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 2, entries: [{ id: 'from-the-future' }] })); + await page.setInputFiles('#import', futurePath); + await page.getByText(/newer Timmy app/).waitFor({ timeout: 5000 }); + const afterFuture = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); + assert.deepEqual(afterFuture.sort(), ['legacy-9', 'seed-1'], 'failed import must not mutate the ledger'); + + // Malformed JSON fails safely and leaves the ledger untouched. + const malformedPath = join(workDir, 'malformed-ledger.json'); + await writeFile(malformedPath, '{"schemaVersion":1,"entries":'); + await page.setInputFiles('#import', malformedPath); + await page.getByText(/not a supported Timmy export/).waitFor({ timeout: 5000 }); + const afterMalformed = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); + assert.deepEqual(afterMalformed.sort(), ['legacy-9', 'seed-1'], 'malformed import must not mutate the ledger'); + await page.screenshot({ path: 'artifacts/portability-import-mobile.png', fullPage: false }); + + // Byte-limit enforcement in the browser: File.size is checked BEFORE the + // file is read, and UTF-8 bytes (not JS characters) are the measured unit. + const MAX_IMPORT_BYTES = await page.evaluate(async () => (await import('/src/domain.js')).MAX_IMPORT_BYTES); + const oversizePath = join(workDir, 'oversize-ledger.json'); + await writeFile(oversizePath, Buffer.concat([ + Buffer.from('{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"'), + Buffer.alloc(MAX_IMPORT_BYTES + 1, 0x6e), + Buffer.from('"}]}'), + ])); + let textReads = 0; + await page.evaluate(() => { + const original = File.prototype.text; + File.prototype.text = function (...args) { + window.__fileTextReads = (window.__fileTextReads || 0) + 1; + return original.apply(this, args); + }; + }); + await page.setInputFiles('#import', oversizePath); + await page.getByText(/too large/i).waitFor({ timeout: 5000 }); + textReads = await page.evaluate(() => window.__fileTextReads || 0); + assert.equal(textReads, 0, 'oversized files must be rejected by File.size before File.text()'); + const afterOversize = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); + assert.deepEqual(afterOversize.sort(), ['legacy-9', 'seed-1'], 'oversized import must not mutate the ledger'); + + // Multibyte boundary: a payload whose UTF-8 byte size exceeds the cap while + // its JS character count does not must still be rejected (byte-exact limit). + const emojiHead = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":[{"id":"e","occurredAt":"2026-08-20T09:00:00.000Z","bristolType":4,"color":"brown","urgency":0,"discomfort":0,"note":"'; + const emojiTail = '"}]}'; + const emojiNoteUnits = Math.ceil(MAX_IMPORT_BYTES / 3); // ~1.33x cap in UTF-8 bytes, ~0.67x cap in JS units + const multibyteOverBytesPath = join(workDir, 'multibyte-over-bytes.json'); + await writeFile(multibyteOverBytesPath, Buffer.from(emojiHead + '💩'.repeat(emojiNoteUnits) + emojiTail, 'utf8')); + const multibyteStats = await page.evaluate(payload => { + return { bytes: new TextEncoder().encode(payload).length, units: payload.length }; + }, emojiHead + '💩'.repeat(emojiNoteUnits) + emojiTail); + assert.ok(multibyteStats.bytes > MAX_IMPORT_BYTES, 'fixture must exceed the cap in UTF-8 bytes'); + assert.ok(multibyteStats.units <= MAX_IMPORT_BYTES, 'fixture must stay under the cap in JS characters'); + await page.setInputFiles('#import', multibyteOverBytesPath); + await page.getByText(/too large/i).waitFor({ timeout: 5000 }); + const afterMultibyte = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); + assert.deepEqual(afterMultibyte.sort(), ['legacy-9', 'seed-1'], 'multibyte over-byte import must not mutate the ledger'); + // A dense multibyte payload just UNDER the byte cap still imports cleanly. + const underBytesPath = join(workDir, 'multibyte-under-bytes.json'); + const underPayload = JSON.stringify({ + product: 'Timmy the Talking Turd', + schemaVersion: 1, + exportedAt: '2026-08-22T00:00:00.000Z', + entries: [{ id: 'under-1', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'café ☕'.repeat(2000), photoDataUrl: '', symptoms: {} }], + }); + const underBytes = await page.evaluate(payload => new TextEncoder().encode(payload).length, underPayload); + assert.ok(underBytes <= MAX_IMPORT_BYTES && underBytes > 10000, 'under-cap fixture must carry real multibyte mass'); + await writeFile(underBytesPath, Buffer.from(underPayload, 'utf8')); + await page.setInputFiles('#import', underBytesPath); + await page.getByText('Ledger imported').waitFor({ timeout: 5000 }); + const afterUnderBytes = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); + assert.ok(afterUnderBytes.includes('under-1'), 'valid multibyte import lands in the ledger'); + + // Collision-safe merge: re-importing a file whose ids already exist must + // not duplicate or overwrite anything and must say so explicitly. + const beforeReimport = await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE); + await page.setInputFiles('#import', underBytesPath); + await page.getByText(/already in your ledger/i).waitFor({ timeout: 5000 }); + const afterReimport = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); + assert.deepEqual(afterReimport.sort(), JSON.parse(beforeReimport).map(entry => entry.id).sort(), 're-import is idempotent: no duplicates, no overwrites'); + + // RED 1 — transactional import: a quota failure during save must roll back + // BOTH the in-memory ledger and localStorage together. + const beforeQuota = await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE); + const domCountBeforeQuota = await page.locator('.entry').count(); + await page.evaluate(() => { + // Simulate storage exhaustion at the exact moment of persistence, + // keeping the original setter around for a faithful restore. + const real = Object.getOwnPropertyDescriptor(Storage.prototype, 'setItem'); + window.__realSetItem = real; + Object.defineProperty(Storage.prototype, 'setItem', { + ...real, + value: function setItem() { throw new DOMException('quota exceeded', 'QuotaExceededError'); }, + }); + }); + const quotaImportPath = join(workDir, 'quota-import.json'); + await writeFile(quotaImportPath, JSON.stringify([ + { id: 'quota-new-1', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'arrives right before quota failure', photoDataUrl: '', symptoms: {} }, + ])); + await page.setInputFiles('#import', quotaImportPath); + await page.getByText(/storage is full/i).waitFor({ timeout: 5000 }); + const afterQuotaStorage = await page.evaluate(([storeKey]) => localStorage.getItem(storeKey), [ROOT_STORE]); + assert.equal(afterQuotaStorage, beforeQuota, 'localStorage must be untouched after a failed save'); + const inMemoryIds = await page.evaluate(() => Array.from(document.querySelectorAll('.entry strong')).map(node => node.textContent)); + assert.equal(await page.locator('.entry').count(), domCountBeforeQuota, 'rendered journal shows no partially imported rows'); + assert.ok(!JSON.stringify(inMemoryIds).includes('quota-new-1'), 'in-memory ledger rolled back with storage'); + await page.evaluate(() => { + Object.defineProperty(Storage.prototype, 'setItem', window.__realSetItem); + delete window.__realSetItem; + }); + + // RED 2 — pre-render migration: malformed stored history (invalid dates, + // duplicate ids) is repaired before render instead of blanking the UI. + await page.evaluate(() => { + localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([ + { id: 'broken-date', occurredAt: 'not-a-real-date', bristolType: 3, note: 'date was corrupted' }, + { id: 'twin-row', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, note: 'twin one' }, + { id: 'twin-row', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 6, note: 'twin two' }, + ])); + }); + await page.reload({ waitUntil: 'networkidle' }); + assert.ok((await page.getByText(/Timmy noticed|Journal/).count()) > 0 || (await page.locator('#app').innerText()).length > 0, 'app renders over malformed storage'); + await page.locator('[data-view="calendar"]').last().click(); + assert.equal(await page.locator('.entry').count(), 3, 'invalid date did not blank the UI; every distinct record renders'); + const migratedStore = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey)), ROOT_STORE); + const brokenRow = migratedStore.find(entry => entry.note === 'date was corrupted'); + assert.ok(brokenRow && !Number.isNaN(new Date(brokenRow.occurredAt).getTime()), 'stored invalid dates become valid ISO timestamps'); + const twinRows = migratedStore.filter(entry => entry.id.startsWith('twin-row')); + assert.deepEqual(twinRows.map(entry => entry.id).sort(), ['twin-row', 'twin-row#2'], 'duplicate ids repaired deterministically in storage'); + + // RED 3 — ambiguous top-level arrays are rejected wholesale in the browser. + const unrelatedPath = join(workDir, 'unrelated-array.json'); + await writeFile(unrelatedPath, JSON.stringify([{ userId: 7, email: 'person@example.com', preferences: { theme: 'dark' } }])); + const beforeUnrelated = await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE); + await page.locator('[data-view="privacy"]').click(); + await page.setInputFiles('#import', unrelatedPath); + await page.getByText(/not a supported Timmy export/).waitFor({ timeout: 5000 }); + assert.equal( + await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE), + beforeUnrelated, + 'rejected unrelated arrays never mutate the ledger', + ); + + // RED 4 — mislabeled SVG photos are dropped by canonical raster validation + // while genuinely canonical photos survive import untouched. + const svgB64 = Buffer.from('').toString('base64'); + const fakePhotoEntry = { + id: 'fake-photo', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', + urgency: 0, discomfort: 0, note: 'smuggled svg', symptoms: {}, + photoDataUrl: `data:image/jpeg;base64,${svgB64}`, + }; + const realJpeg = Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.alloc(600, 0x33)]).toString('base64'); + const realPhotoEntry = { + id: 'real-photo', occurredAt: '2026-08-20T10:00:00.000Z', bristolType: 2, color: 'green', + urgency: 1, discomfort: 0, note: 'genuine raster', symptoms: {}, + photoDataUrl: `data:image/jpeg;base64,${realJpeg}`, + }; + const photoImportPath = join(workDir, 'photo-contract.json'); + await writeFile(photoImportPath, JSON.stringify([fakePhotoEntry, realPhotoEntry])); + await page.setInputFiles('#import', photoImportPath); + await page.getByText('Ledger imported').waitFor({ timeout: 5000 }); + const afterPhotos = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey)), ROOT_STORE); + const fakeStored = afterPhotos.find(entry => entry.id === 'fake-photo'); + assert.ok(fakeStored, 'the record itself still imports'); + assert.equal(fakeStored.photoDataUrl, '', 'mislabeled SVG payload is stripped from the entry'); + const realStored = afterPhotos.find(entry => entry.id === 'real-photo'); + assert.equal(realStored.photoDataUrl, `data:image/jpeg;base64,${realJpeg}`, 'canonical raster photos survive byte-for-byte'); + + // RED 5 — duplicate-ID repair preserves both distinct local records in the + // full app flow, and the repair lands in storage. + await page.evaluate(() => { + localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([ + { id: 'dup-pair', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'first distinct record', photoDataUrl: '', symptoms: {} }, + { id: 'dup-pair', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 6, color: 'yellow', urgency: 1, discomfort: 0, note: 'second distinct record', photoDataUrl: '', symptoms: {} }, + ])); + }); + await page.reload({ waitUntil: 'networkidle' }); + await page.locator('[data-view="calendar"]').last().click(); + assert.equal(await page.locator('.entry').count(), 2, 'both duplicate-id records render as distinct rows'); + const dupStore = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey)), ROOT_STORE); + const dupIds = dupStore.map(entry => entry.id).sort(); + assert.deepEqual(dupIds, ['dup-pair', 'dup-pair#2'], 'storage holds two distinct deterministic ids'); + + // Delete Everything removes every namespaced copy of the local ledger. + await page.locator('[data-view="privacy"]').click(); + await page.locator('#delete-all').click(); + await page.getByText('Local ledger deleted').waitFor({ timeout: 5000 }); + const storesAfterDelete = await page.evaluate(([rootStore, legacyStore]) => ({ + root: localStorage.getItem(rootStore), + legacy: localStorage.getItem(legacyStore), + }), [ROOT_STORE, LEGACY_STORE]); + assert.equal(storesAfterDelete.root, null, 'namespaced store cleared'); + assert.equal(storesAfterDelete.legacy, null, 'legacy store cleared'); + await page.locator('[data-view="calendar"]').last().click(); + assert.equal(await page.locator('.entry').count(), 0, 'journal is empty after delete-all'); + + // Base-path deployments keep their ledger in an isolated namespace. + const staging = spawn(process.execPath, ['server.mjs'], { + env: { ...process.env, PORT: '4179', HOST: '127.0.0.1', TIMMY_BASE_PATH: '/timmy-staging' }, + stdio: 'ignore', + }); + try { + let up = false; + for (let attempt = 0; attempt < 40 && !up; attempt += 1) { + up = await fetch('http://127.0.0.1:4179/timmy-staging/api/healthz').then(response => response.ok).catch(() => false); + if (!up) await sleep(250); + } + assert.ok(up, 'staging server must start'); + const stagingContext = await browser.newContext({ viewport: { width: 390, height: 844 }, serviceWorkers: 'block' }); + const stagingPage = await stagingContext.newPage(); + stagingPage.on('dialog', dialog => dialog.accept()); + await stagingPage.goto('http://127.0.0.1:4179/timmy-staging', { waitUntil: 'networkidle' }); + await stagingPage.evaluate(() => { + localStorage.clear(); + localStorage.setItem('timmy:/timmy-staging:ledger-v1', JSON.stringify([ + { id: 'staging-1', occurredAt: '2026-08-21T10:00:00.000Z', bristolType: 3, color: 'brown', urgency: 0, discomfort: 0, note: 'staging only', photoDataUrl: '', symptoms: {} }, + ])); + // Legacy root-deployment ledger living in the same origin's storage. + localStorage.setItem('timmy-ledger-v1', JSON.stringify([ + { id: 'root-legacy-1', occurredAt: '2026-08-19T09:00:00.000Z', bristolType: 3, color: 'brown', urgency: 0, discomfort: 0, note: 'root legacy ledger', photoDataUrl: '', symptoms: {} }, + ])); + }); + await stagingPage.reload({ waitUntil: 'networkidle' }); + await stagingPage.locator('[data-view="calendar"]').last().click(); + assert.equal(await stagingPage.locator('.entry').count(), 1, 'staging entry renders under its base path'); + const isolation = await stagingPage.evaluate(() => ({ + staging: localStorage.getItem('timmy:/timmy-staging:ledger-v1'), + root: localStorage.getItem('timmy:/:ledger-v1'), + legacy: localStorage.getItem('timmy-ledger-v1'), + })); + assert.ok(isolation.staging, 'staging store keeps its data'); + assert.equal(isolation.root, null, 'root-namespaced store untouched by staging data'); + assert.ok(isolation.legacy && JSON.parse(isolation.legacy).some(entry => entry.id === 'root-legacy-1'), 'legacy store untouched by staging session'); + await stagingPage.locator('[data-view="privacy"]').click(); + await stagingPage.locator('#delete-all').click(); + await stagingPage.getByText('Local ledger deleted').waitFor({ timeout: 5000 }); + const stagingAfterDelete = await stagingPage.evaluate(() => localStorage.getItem('timmy:/timmy-staging:ledger-v1')); + assert.equal(stagingAfterDelete, null, 'delete-all clears the base-path store'); + const legacyAfterStagingDelete = await stagingPage.evaluate(() => localStorage.getItem('timmy-ledger-v1')); + assert.ok( + legacyAfterStagingDelete && JSON.parse(legacyAfterStagingDelete).some(entry => entry.id === 'root-legacy-1'), + 'base-path delete-all must not erase another namespace’s global legacy ledger', + ); + await stagingContext.close(); + } finally { + staging.kill(); + } + + assert.deepEqual(errors, [], 'no console or page errors'); + await context.close(); + console.log('PASS ledger portability: export round trip, merge import, safe failures, isolation, delete-all'); + } finally { + await browser.close(); + await rm(workDir, { recursive: true, force: true }); + } +}); diff --git a/tests/photo-first.acceptance.mjs b/tests/photo-first.acceptance.mjs index 0850e36..ab53618 100644 --- a/tests/photo-first.acceptance.mjs +++ b/tests/photo-first.acceptance.mjs @@ -23,7 +23,8 @@ await page.route('**/api/analyze', route => route.fulfill({ warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.', }), })); -await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); +const appUrl = process.env.TIMMY_TEST_URL || 'http://127.0.0.1:4173'; +await page.goto(appUrl, { waitUntil: 'networkidle' }); await page.evaluate(() => localStorage.clear()); await page.reload({ waitUntil: 'networkidle' }); diff --git a/tests/service-worker-runtime.test.js b/tests/service-worker-runtime.test.js index e93fa4e..c497392 100644 --- a/tests/service-worker-runtime.test.js +++ b/tests/service-worker-runtime.test.js @@ -43,7 +43,7 @@ async function dispatchFetch(handler, request) { return response; } -test('root activation deletes only its obsolete Timmy caches including the legacy v4 cache', async () => { +test('root activation deletes only its obsolete Timmy caches including the legacy v4 and previous v5 shells', async () => { const { listeners, deleted } = loadWorker({ cacheKeys: [ 'timmy-shell-v4', @@ -56,7 +56,7 @@ test('root activation deletes only its obsolete Timmy caches including the legac await dispatchExtendable(listeners.get('activate')); - assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4']); + assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4', 'timmy-shell:/:v5']); }); test('offline shell lookup uses only the current named cache', async () => { diff --git a/tests/staging-health.test.js b/tests/staging-health.test.js index 6258f99..521c635 100644 --- a/tests/staging-health.test.js +++ b/tests/staging-health.test.js @@ -148,6 +148,23 @@ test('malformed and escaping base paths are rejected at startup', async () => { '/timmy\nstaging', ]; + // The rejection must happen at startup, so the wait budget is anchored to + // the measured cold-start time of the server itself rather than a magic + // constant that silently breaks on loaded or cold machines. + const timedSpawn = () => new Promise(resolve => { + const startedAt = Date.now(); + const child = spawn(process.execPath, ['server.mjs'], { + cwd: root, + env: { ...process.env, PORT: '0', TIMMY_BASE_PATH: '/' }, + stdio: ['ignore', 'ignore', 'ignore'], + }); + child.once('exit', () => resolve(Date.now() - startedAt)); + setTimeout(() => { child.kill('SIGKILL'); }, 5000); + }); + await timedSpawn(); // warm caches once; timing after this reflects steady state + const baseline = await timedSpawn(); + const budget = Math.max(2000, baseline * 4 + 500); + for (const value of invalid) { const child = spawn(process.execPath, ['server.mjs'], { cwd: root, @@ -158,7 +175,7 @@ test('malformed and escaping base paths are rejected at startup', async () => { child.stderr.on('data', chunk => { stderr += chunk; }); const exitCode = await Promise.race([ new Promise(resolve => child.once('exit', resolve)), - new Promise(resolve => setTimeout(() => { child.kill('SIGTERM'); resolve('timeout'); }, 800)), + new Promise(resolve => setTimeout(() => { child.kill('SIGTERM'); resolve('timeout'); }, budget)), ]); assert.notEqual(exitCode, 'timeout', `${JSON.stringify(value)} was accepted`); assert.notEqual(exitCode, 0, `${JSON.stringify(value)} exited successfully`); diff --git a/tests/ui.acceptance.mjs b/tests/ui.acceptance.mjs index 2e8761c..9a138cb 100644 --- a/tests/ui.acceptance.mjs +++ b/tests/ui.acceptance.mjs @@ -9,7 +9,8 @@ const page = await context.newPage(); const errors = []; page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); page.on('pageerror', error => errors.push(error.message)); -await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); +const appUrl = process.env.TIMMY_TEST_URL || 'http://127.0.0.1:4173'; +await page.goto(appUrl, { waitUntil: 'networkidle' }); await page.evaluate(() => localStorage.clear()); await page.reload({ waitUntil: 'networkidle' });