fix: close second hostile-review round on ledger portability
All checks were successful
Quality gates / quality (pull_request) Successful in 4m14s
All checks were successful
Quality gates / quality (pull_request) Successful in 4m14s
- collision-safe ID repair: duplicate ids inside stored data are repaired deterministically (first keeps id, twins get id#2, id#3, ... scanning past owned suffixes); every distinct local record survives, never dropped or silently merged; hostile id types (Symbol/BigInt/objects) repair onto fresh deterministic ids instead of throwing - transactional import: parse+merge into a candidate ledger, persist first, then commit memory; quota/error rolls back in-memory state and localStorage together with explicit user feedback; total 16MiB portability budget enforced before mutation on export, import (post-migration expansion), and storage writes - strict Timmy legacy contract for bare top-level arrays: nonempty array of plain rows each carrying a nonempty string id and integer Bristol 1-7; arbitrary unrelated arrays are rejected wholesale - no invented medical defaults from foreign JSON - canonical raster photo validation: strict JPEG/PNG/WebP grammar, canonical base64 (linear scan, no regex on multi-MB strings), atob round-trip decode, declared-format magic bytes, 32B-4MiB decoded bounds; mislabeled SVG/HTML and noncanonical tiny junk are stripped while genuine photos survive byte-for-byte - migrateStoredLedger: localStorage is validated and migrated before render; invalid dates become safe ISO timestamps, duplicate ids repaired, junk rows dropped (never fabricated into default records); healthy storage is byte-stable and never rewritten - sanitizeEntry absorbs Symbol/BigInt/hostile dates/throwing toString, valueOf, getTime, toJSON without throwing; results stay serializable - browser regression suite: quota rollback, pre-render migration, array rejection, photo contract, duplicate-ID preservation in the real app flow - staging-health startup-rejection budget anchored to measured server cold-start instead of a fixed 800ms (fixes load-sensitive flake)
This commit is contained in:
parent
dd86d6675d
commit
6f73b8551c
41
app.js
41
app.js
|
|
@ -1,4 +1,4 @@
|
|||
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, MAX_IMPORT_BYTES, mergeLedgers, 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,7 +129,28 @@ 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 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)}}
|
||||
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()}
|
||||
|
|
@ -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<sample.length;i+=4)total+=(sample[i]+sample[i+1]+sample[i+2])/3;URL.revokeObjectURL(url);resolve({dataUrl:canvas.toDataURL('image/jpeg',.7),width:img.width,height:img.height,brightness:total/(sample.length/4)/255})};img.onerror=()=>{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();
|
||||
|
|
|
|||
233
src/domain.js
233
src/domain.js
|
|
@ -57,19 +57,102 @@ export function buildTimmySummary(entries = []) {
|
|||
|
||||
// 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}$/;
|
||||
// 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 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();
|
||||
// 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 = {}) {
|
||||
|
|
@ -82,14 +165,14 @@ export function sanitizeEntry(input = {}) {
|
|||
? input.provenance.origin
|
||||
: null;
|
||||
const entry = {
|
||||
id: String(input.id || globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`),
|
||||
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: 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' && APPROVED_PHOTO_DATA_URL.test(input.photoDataUrl) ? input.photoDataUrl : '',
|
||||
note: typeof input.note === 'string' ? input.note.trim().slice(0, 500) : '',
|
||||
photoDataUrl: canonicalRasterPhoto(input.photoDataUrl),
|
||||
symptoms,
|
||||
};
|
||||
if (provenanceOrigin) entry.provenance = { origin: provenanceOrigin };
|
||||
|
|
@ -103,42 +186,79 @@ 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({
|
||||
const text = JSON.stringify({
|
||||
product: PRODUCT_NAME,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
exportedAt,
|
||||
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: 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);
|
||||
// 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(String(rawEntry?.id ?? entry.id ?? ''));
|
||||
skippedIds.push(rawId || entry.id);
|
||||
continue;
|
||||
}
|
||||
seen.add(entry.id);
|
||||
|
|
@ -161,6 +281,20 @@ 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) {
|
||||
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.');
|
||||
|
|
@ -170,7 +304,10 @@ export function importLedger(text) {
|
|||
} catch {
|
||||
throw new Error('This is not a supported Timmy export.');
|
||||
}
|
||||
if (Array.isArray(parsed)) return parsed.map(sanitizeEntry);
|
||||
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);
|
||||
|
|
@ -180,7 +317,45 @@ export function importLedger(text) {
|
|||
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.`);
|
||||
}
|
||||
return parsed.entries.map(sanitizeEntry);
|
||||
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]);
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ import {
|
|||
exportLedger,
|
||||
hasUrgentLedgerContext,
|
||||
importLedger,
|
||||
estimateLedgerBytes,
|
||||
MAX_IMPORT_BYTES,
|
||||
mergeLedgers,
|
||||
migrateStoredLedger,
|
||||
utf8ByteLength,
|
||||
photoQualityMessage,
|
||||
sanitizeEntry,
|
||||
|
|
@ -247,18 +249,25 @@ test('import rejects oversized payloads by UTF-8 bytes regardless of composition
|
|||
});
|
||||
|
||||
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).
|
||||
// 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 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(',')}]}`;
|
||||
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.95, 'payload must sit close to the boundary');
|
||||
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é ☕');
|
||||
});
|
||||
|
||||
|
|
@ -315,6 +324,297 @@ test('re-importing the same file twice changes nothing (idempotent)', () => {
|
|||
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('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
|
||||
const svgAsJpeg = `data:image/jpeg;base64,${svgPayload.toString('base64')}`;
|
||||
const htmlAsPng = `data:image/png;base64,${Buffer.from('<html><body>hi</body></html>').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);
|
||||
|
|
@ -348,9 +648,11 @@ test('current-schema numeric fields are strict integers within clinical bounds',
|
|||
});
|
||||
|
||||
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==';
|
||||
// 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);
|
||||
|
|
@ -363,6 +665,10 @@ test('photo fields accept only approved raster JPEG/PNG/WebP base64 data URLs',
|
|||
'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)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => {
|
|||
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('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
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' });
|
||||
|
||||
|
|
@ -139,7 +139,110 @@ await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => {
|
|||
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('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>').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]) => ({
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user