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 c8eee79..663f81e 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, exportLedger, hasUrgentLedgerContext, importLedger, MAX_IMPORT_BYTES, mergeLedgers, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js'; import { mergeVisualSuggestion } from './src/analysis.js'; const runtimeConfig = { @@ -119,8 +119,8 @@ 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=[...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.');const text=await file.text();const result=mergeLedgers(entries,importLedger(text));entries=result.merged;saveEntries();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){toast(err.message)}} +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(){ 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/service-worker.js b/service-worker.js index 9a60f51..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}v6`; +const CACHE = `${CACHE_NAMESPACE}v7`; const ASSETS = [ '', 'index.html', diff --git a/src/domain.js b/src/domain.js index 2964163..e804dd3 100644 --- a/src/domain.js +++ b/src/domain.js @@ -1,5 +1,6 @@ const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas']; -const KNOWN_PROVENANCE_ORIGINS = Object.freeze({ user: true, 'ai-suggestion': true }); +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], @@ -54,23 +55,41 @@ 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 approved raster JPEG/PNG/WebP base64 data URLs only — SVG, GIF, and +// non-base64 payloads are script-execution and smuggling risks and are dropped. +const APPROVED_PHOTO_DATA_URL = /^data:image\/(?:jpeg|png|webp);base64,[A-Za-z0-9+/]+={0,2}$/; + +function toSchemaInteger(value, { min, max, fallback }) { + if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) return fallback; + return value; +} + +// Invalid or missing dates must never throw and never persist Invalid Date. +function safeIsoTimestamp(value) { + const parsed = value instanceof Date ? value : new Date(value); + return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : new Date().toISOString(); +} + 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)); + const bristolType = toSchemaInteger(input.bristolType, { min: 1, max: 7, fallback: 4 }); const provenanceOrigin = input.provenance && typeof input.provenance === 'object' - && input.provenance.origin in KNOWN_PROVENANCE_ORIGINS + && typeof input.provenance.origin === 'string' + && KNOWN_PROVENANCE_ORIGINS.has(input.provenance.origin) ? input.provenance.origin : null; const entry = { id: String(input.id || globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`), - occurredAt: new Date(input.occurredAt || Date.now()).toISOString(), + 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)), + urgency: toSchemaInteger(input.urgency, { min: 0, max: 4, fallback: 0 }), + discomfort: toSchemaInteger(input.discomfort, { min: 0, max: 4, fallback: 0 }), note: String(input.note || '').trim().slice(0, 500), - photoDataUrl: typeof input.photoDataUrl === 'string' && input.photoDataUrl.startsWith('data:image/') ? input.photoDataUrl : '', + photoDataUrl: typeof input.photoDataUrl === 'string' && APPROVED_PHOTO_DATA_URL.test(input.photoDataUrl) ? input.photoDataUrl : '', symptoms, }; if (provenanceOrigin) entry.provenance = { origin: provenanceOrigin }; @@ -93,14 +112,58 @@ export function exportLedger(entries, exportedAt = new Date().toISOString()) { }, null, 2); } -export const MAX_IMPORT_BYTES = 2 * 1024 * 1024; +export function utf8ByteLength(text) { + return UTF8_ENCODER.encode(text).length; +} + +// Collision-safe deterministic merge: existing (user-owned) rows always win; +// incoming rows are added only when their id is new. Re-importing a file can +// never duplicate, overwrite, or shadow user records, and every collision is +// reported back for explicit user feedback. +export function mergeLedgers(existing, incoming) { + const local = Array.isArray(existing) + ? existing.filter(entry => entry && typeof entry === 'object' && !Array.isArray(entry)) + : []; + const seen = new Set(); + const merged = []; + for (const entry of local) { + const id = String(entry.id ?? ''); + if (!id || seen.has(id)) continue; + seen.add(id); + merged.push(entry); + } + const added = []; + const skippedIds = []; + const source = Array.isArray(incoming) ? incoming : []; + for (const rawEntry of source) { + const entry = sanitizeEntry(rawEntry || {}); + if (!entry.id || seen.has(entry.id)) { + skippedIds.push(String(rawEntry?.id ?? 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]); export function importLedger(text) { if (typeof text !== 'string' || text.length === 0) throw new Error('This is not a supported Timmy export.'); - if (text.length > MAX_IMPORT_BYTES) throw new RangeError('That file is too large to be a 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); diff --git a/tests/domain.test.js b/tests/domain.test.js index e2df743..c435a94 100644 --- a/tests/domain.test.js +++ b/tests/domain.test.js @@ -10,6 +10,8 @@ import { hasUrgentLedgerContext, importLedger, MAX_IMPORT_BYTES, + mergeLedgers, + utf8ByteLength, photoQualityMessage, sanitizeEntry, } from '../src/domain.js'; @@ -142,6 +144,17 @@ test('rejects provenance origins outside the recorded vocabulary', () => { } }); +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); @@ -210,6 +223,175 @@ test('import rejects oversized ledgers before parsing user data', () => { 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 to just under the byte + // cap without tripping per-field bounds (note <= 500 chars). + 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 perEntryBytes = utf8ByteLength(JSON.stringify(makeEntry(0))) + 1; // + comma + const budget = Math.floor((MAX_IMPORT_BYTES - utf8ByteLength(head) - 2) * 0.97); + const count = Math.max(1, Math.floor(budget / perEntryBytes)); + const payload = `${head}${Array.from({ length: count }, (_, i) => JSON.stringify(makeEntry(i))).join(',')}]}`; + assert.ok(utf8ByteLength(payload) <= MAX_IMPORT_BYTES); + assert.ok(utf8ByteLength(payload) > MAX_IMPORT_BYTES * 0.95, 'payload must sit close to the boundary'); + const imported = importLedger(payload); + assert.equal(imported.length, count, 'every packed entry survives'); + 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('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', () => { + const okJpeg = `data:image/jpeg;base64,${Buffer.from('ok').toString('base64')}`; + const okPng = 'data:image/png;base64,iVBORw0KGgo='; + const okWebp = 'data:image/webp;base64,UklGRg=='; + 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, + ]) { + 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({ @@ -237,9 +419,9 @@ test('round trip preserves confirmed values and provenance without leaking secre ); assert.equal(roundTripped[0].symptoms.blood, false); assert.deepEqual(roundTripped[0].provenance, { origin: 'ai-suggestion' }); - assert.equal(roundTripped[1].bristolType, 7); + 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, 4); + 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); }); diff --git a/tests/ledger-portability.acceptance.mjs b/tests/ledger-portability.acceptance.mjs index acf25ac..c6e6dfc 100644 --- a/tests/ledger-portability.acceptance.mjs +++ b/tests/ledger-portability.acceptance.mjs @@ -75,6 +75,70 @@ await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => { 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'); + // Delete Everything removes every namespaced copy of the local ledger. await page.locator('#delete-all').click(); await page.getByText('Local ledger deleted').waitFor({ timeout: 5000 }); @@ -108,6 +172,10 @@ await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => { 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(); @@ -119,12 +187,17 @@ await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => { })); assert.ok(isolation.staging, 'staging store keeps its data'); assert.equal(isolation.root, null, 'root-namespaced store untouched by staging data'); - assert.equal(isolation.legacy, null, 'legacy 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();