fix: close hostile-review blockers in ledger portability
Some checks failed
Quality gates / quality (pull_request) Failing after 1m28s
Some checks failed
Quality gates / quality (pull_request) Failing after 1m28s
- provenance origin set is own-safe exact membership (Set.has); inherited toString/constructor/__proto__ names can never become origins - import/export symmetry restored with an explicit bounded policy: MAX_IMPORT_BYTES raised 2 MiB -> 16 MiB UTF-8 bytes, above any export this app can produce (photos capped at 4 MiB binary), so valid exports always re-import without silent data loss while hostile files stay bounded - byte limit is byte-exact now: utf8ByteLength() measures real UTF-8 bytes (multibyte boundaries tested), and the browser rejects oversized files by File.size BEFORE File.text() reads user data - collision-safe deterministic mergeLedgers(): existing user-owned rows win, incoming rows only ever added for new ids, intra-file duplicates collapse deterministically, every collision reported explicitly in the import toast (no duplicate/overwrite/shadow of user records) - base-path Delete Everything is namespace-scoped: root still cleans/ migrates the legacy store to prevent resurrection, /timmy-staging no longer erases another namespace's global legacy ledger (browser regression covers deletion with root legacy data present) - strict current-schema values: Bristol 1-7 / urgency 0-4 / discomfort 0-4 must be true integers (out-of-range falls back instead of silent clamping), photos restricted to JPEG/PNG/WebP base64 raster data URLs (SVG/GIF/non-base64 dropped), invalid dates never throw or persist Invalid Date values Verification: npm test 91/91, test:ui/test:photo/test:sleek/test:portability PASS, staging-deploy 20/20 OK, check:syntax clean, npm audit 0 high, check_diff clean, adversarial probe battery (exact-byte boundary at cap, prototype pollution via JSON, lone surrogates, data-URL strictness) green.
This commit is contained in:
parent
b8532f587d
commit
dd86d6675d
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,3 +5,4 @@ __pycache__/
|
|||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
.worktrees/
|
||||
|
|
|
|||
6
app.js
6
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(){
|
||||
|
|
|
|||
BIN
artifacts/portability-import-mobile.png
Normal file
BIN
artifacts/portability-import-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,<svg onload="alert(1)">',
|
||||
'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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user