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.
435 lines
21 KiB
JavaScript
435 lines
21 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
bucketForBristolType,
|
|
buildTimmySummary,
|
|
detectUrgentFlags,
|
|
detectUrgentText,
|
|
exportLedger,
|
|
hasUrgentLedgerContext,
|
|
importLedger,
|
|
MAX_IMPORT_BYTES,
|
|
mergeLedgers,
|
|
utf8ByteLength,
|
|
photoQualityMessage,
|
|
sanitizeEntry,
|
|
} from '../src/domain.js';
|
|
|
|
test('maps Bristol types to clinically grounded buckets', () => {
|
|
assert.equal(bucketForBristolType(1), 'constipation');
|
|
assert.equal(bucketForBristolType(2), 'constipation');
|
|
assert.equal(bucketForBristolType(3), 'typical');
|
|
assert.equal(bucketForBristolType(4), 'typical');
|
|
assert.equal(bucketForBristolType(5), 'loose');
|
|
assert.equal(bucketForBristolType(7), 'loose');
|
|
assert.equal(bucketForBristolType(0), 'unknown');
|
|
});
|
|
|
|
test('escalates reported blood, black stool, severe pain, vomiting, fever, or inability to pass gas', () => {
|
|
const result = detectUrgentFlags({
|
|
blood: true,
|
|
blackOrDarkRed: false,
|
|
severePain: true,
|
|
vomiting: false,
|
|
fever: false,
|
|
cannotPassGas: false,
|
|
});
|
|
assert.equal(result.urgent, true);
|
|
assert.deepEqual(result.flags, ['blood', 'severePain']);
|
|
assert.match(result.message, /medical care/i);
|
|
});
|
|
|
|
test('does not invent reassurance when no urgent flags are reported', () => {
|
|
const result = detectUrgentFlags({});
|
|
assert.equal(result.urgent, false);
|
|
assert.deepEqual(result.flags, []);
|
|
assert.match(result.message, /not a diagnosis/i);
|
|
});
|
|
|
|
test('detects common urgent symptom language without matching unrelated blood wording', () => {
|
|
for (const message of [
|
|
'I have rectal bleeding',
|
|
'There is blood in my stool',
|
|
'My stool is black',
|
|
'I have severe stomach pain',
|
|
'I am throwing up and have a fever',
|
|
'I threw up',
|
|
'I puked twice',
|
|
'I am barfing',
|
|
'I barfed',
|
|
'I hurled',
|
|
'She hurls',
|
|
'I am upchucking',
|
|
'I spewed',
|
|
'She spews',
|
|
'I tossed my cookies',
|
|
'She tosses her cookies',
|
|
'He tossed his cookies',
|
|
'Someone is tossing their cookies',
|
|
'I lost my lunch',
|
|
'She loses her lunch',
|
|
'He lost his lunch',
|
|
'Someone is losing their lunch',
|
|
'I have emesis',
|
|
'I am unable to pass gas',
|
|
]) assert.equal(detectUrgentText(message).urgent, true, message);
|
|
assert.equal(detectUrgentText('My blood pressure was checked').urgent, false);
|
|
for (const nonVomiting of [
|
|
'She hurled the javelin across the field.',
|
|
'He hurls insults when angry.',
|
|
'They are hurling rocks at the wall.',
|
|
'He spewed hateful rhetoric.',
|
|
'The volcano spews ash.',
|
|
'The pipe is spewing water.',
|
|
]) assert.equal(detectUrgentText(nonVomiting).urgent, false, nonVomiting);
|
|
});
|
|
|
|
test('detects urgent flags or language in confirmed ledger context', () => {
|
|
assert.equal(hasUrgentLedgerContext([{ symptoms: { cannotPassGas: true }, note: '' }]), true);
|
|
assert.equal(hasUrgentLedgerContext([{ symptoms: {}, note: 'I have rectal bleeding' }]), true);
|
|
assert.equal(hasUrgentLedgerContext([{ symptoms: {}, note: 'ordinary entry' }]), false);
|
|
});
|
|
|
|
test('Timmy summary reports patterns without clearing food or diagnosing disease', () => {
|
|
const entries = [
|
|
{ bristolType: 3, occurredAt: '2026-08-15T08:00:00.000Z' },
|
|
{ bristolType: 4, occurredAt: '2026-08-16T08:00:00.000Z' },
|
|
{ bristolType: 6, occurredAt: '2026-08-17T08:00:00.000Z' },
|
|
];
|
|
const summary = buildTimmySummary(entries);
|
|
assert.match(summary, /3 logs/);
|
|
assert.match(summary, /2 typical/);
|
|
assert.doesNotMatch(summary, /safe|diagnos|Taco Bell|clear/i);
|
|
});
|
|
|
|
test('sanitizes a user entry to the MVP data contract', () => {
|
|
const entry = sanitizeEntry({
|
|
id: 'abc',
|
|
occurredAt: '2026-08-17T12:00:00.000Z',
|
|
bristolType: 4,
|
|
color: 'brown',
|
|
urgency: 2,
|
|
discomfort: 1,
|
|
note: 'After lunch',
|
|
photoDataUrl: 'data:image/jpeg;base64,abc',
|
|
unexpected: 'drop me',
|
|
});
|
|
assert.deepEqual(Object.keys(entry).sort(), [
|
|
'bristolType', 'color', 'discomfort', 'id', 'note', 'occurredAt',
|
|
'photoDataUrl', 'symptoms', 'urgency'
|
|
].sort());
|
|
assert.equal(entry.unexpected, undefined);
|
|
});
|
|
|
|
test('keeps only a bounded provenance origin and strips smuggled secrets', () => {
|
|
const entry = sanitizeEntry({
|
|
id: 'prov-1',
|
|
bristolType: 4,
|
|
provenance: { origin: 'ai-suggestion', suggestedBristolType: 4, apiToken: 'sk-secret-value', sessionId: 'hermes-session-x' },
|
|
});
|
|
assert.deepEqual(entry.provenance, { origin: 'ai-suggestion' });
|
|
assert.doesNotMatch(JSON.stringify(entry), /secret|session/i);
|
|
});
|
|
|
|
test('omits provenance entirely when none was recorded', () => {
|
|
const entry = sanitizeEntry({ id: 'plain-1', bristolType: 3 });
|
|
assert.equal(entry.provenance, undefined);
|
|
});
|
|
|
|
test('rejects provenance origins outside the recorded vocabulary', () => {
|
|
for (const bogus of ['clinician', 'self-diagnosis', '']) {
|
|
const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: bogus } });
|
|
assert.equal(entry.provenance, undefined, bogus);
|
|
}
|
|
});
|
|
|
|
test('provenance membership is own-property safe: inherited Object names are not origins', () => {
|
|
for (const poisoned of ['toString', 'constructor', '__proto__', 'hasOwnProperty', 'valueOf', 'isPrototypeOf']) {
|
|
const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: poisoned } });
|
|
assert.equal(entry.provenance, undefined, poisoned);
|
|
assert.doesNotMatch(JSON.stringify(entry), new RegExp(poisoned), poisoned);
|
|
}
|
|
// Even a null-prototype provenance carrying a real origin stays acceptable.
|
|
const nullProto = sanitizeEntry({ id: 'y', bristolType: 4, provenance: Object.assign(Object.create(null), { origin: 'user' }) });
|
|
assert.deepEqual(nullProto.provenance, { origin: 'user' });
|
|
});
|
|
|
|
|
|
test('photo quality guidance is deterministic and does not claim visual diagnosis', () => {
|
|
assert.match(photoQualityMessage({ width: 300, height: 300, brightness: 0.5 }), /closer/i);
|
|
assert.match(photoQualityMessage({ width: 1200, height: 900, brightness: 0.02 }), /light/i);
|
|
assert.match(photoQualityMessage({ width: 1200, height: 900, brightness: 0.5 }), /review/i);
|
|
assert.doesNotMatch(photoQualityMessage({ width: 1200, height: 900, brightness: 0.5 }), /type [1-7]|disease|diagnos/i);
|
|
});
|
|
|
|
test('export ledger is portable JSON with version and entries', () => {
|
|
const text = exportLedger([{ id: 'a', bristolType: 4 }], '2026-08-18T00:00:00.000Z');
|
|
const parsed = JSON.parse(text);
|
|
assert.equal(parsed.schemaVersion, 1);
|
|
assert.equal(parsed.exportedAt, '2026-08-18T00:00:00.000Z');
|
|
assert.equal(parsed.entries.length, 1);
|
|
});
|
|
|
|
test('import migrates the legacy bare-array ledger to the current versioned envelope', () => {
|
|
const legacy = JSON.stringify([
|
|
{ id: 'legacy-1', occurredAt: '2026-08-17T12:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'older export' },
|
|
{ id: 'legacy-2', bristolType: 7 },
|
|
]);
|
|
const entries = importLedger(legacy);
|
|
assert.equal(entries.length, 2);
|
|
assert.equal(entries[0].id, 'legacy-1');
|
|
assert.equal(entries[0].bristolType, 2);
|
|
assert.equal(entries[0].note, 'older export');
|
|
});
|
|
|
|
test('import accepts every prior schema version and migrates entries forward', () => {
|
|
for (const version of [0, 1]) {
|
|
const payload = version === 0
|
|
? [{ id: `v${version}`, bristolType: 3 }]
|
|
: { product: 'Timmy the Talking Turd', schemaVersion: version, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: `v${version}`, bristolType: 3 }] };
|
|
const entries = importLedger(JSON.stringify(payload));
|
|
assert.equal(entries.length, 1, `schemaVersion ${version}`);
|
|
assert.equal(entries[0].bristolType, 3, `schemaVersion ${version}`);
|
|
}
|
|
});
|
|
|
|
test('import fails safely on a newer schema version instead of guessing', () => {
|
|
for (const schemaVersion of [2, 99]) {
|
|
assert.throws(
|
|
() => importLedger(JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion, entries: [{ id: 'x' }] })),
|
|
error => error instanceof RangeError && /newer Timmy app/i.test(error.message),
|
|
`schemaVersion ${schemaVersion}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('import fails safely on malformed or wrong-shaped payloads', () => {
|
|
for (const payload of [
|
|
'not json',
|
|
'{"schemaVersion":1,"entries":{}}',
|
|
'{"entries":[]}',
|
|
'{"product":"Other App","schemaVersion":1,"entries":[]}',
|
|
null,
|
|
42,
|
|
]) {
|
|
assert.throws(() => importLedger(payload), /not a supported Timmy export/, JSON.stringify(String(payload)).slice(0, 40));
|
|
}
|
|
});
|
|
|
|
test('import rejects oversized ledgers before parsing user data', () => {
|
|
const huge = JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 1, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: 'x', note: 'n'.repeat(MAX_IMPORT_BYTES + 1024) }] });
|
|
assert.ok(huge.length > MAX_IMPORT_BYTES);
|
|
assert.throws(() => importLedger(huge), RangeError);
|
|
});
|
|
|
|
test('utf8ByteLength measures UTF-8 bytes, not UTF-16 code units', () => {
|
|
assert.equal(utf8ByteLength(''), 0);
|
|
assert.equal(utf8ByteLength('abc'), 3);
|
|
// é is 1 UTF-16 unit but 2 UTF-8 bytes; 💩 is 2 UTF-16 units but 4 UTF-8 bytes.
|
|
assert.equal(utf8ByteLength('é'), 2);
|
|
assert.equal(utf8ByteLength('💩'), 4);
|
|
assert.equal(utf8ByteLength('aé💩b'), 1 + 2 + 4 + 1);
|
|
const emojiBlob = '💩'.repeat(1000);
|
|
assert.equal(emojiBlob.length, 2000, 'sanity: two code units each');
|
|
assert.equal(utf8ByteLength(emojiBlob), 4000);
|
|
});
|
|
|
|
test('import rejects oversized payloads by UTF-8 bytes regardless of composition', () => {
|
|
const asciiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + 'n'.repeat(MAX_IMPORT_BYTES) + '"}]}';
|
|
assert.ok(utf8ByteLength(asciiOver) > MAX_IMPORT_BYTES);
|
|
assert.throws(() => importLedger(asciiOver), RangeError);
|
|
// 4 bytes per glyph: byte size crosses the cap at half the code-unit count.
|
|
const emojiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + '💩'.repeat(Math.ceil(MAX_IMPORT_BYTES / 2)) + '"}]}';
|
|
assert.ok(emojiOver.length <= MAX_IMPORT_BYTES * 1.01, 'code-unit count must not be what trips this');
|
|
assert.ok(utf8ByteLength(emojiOver) > MAX_IMPORT_BYTES);
|
|
assert.throws(() => importLedger(emojiOver), RangeError);
|
|
});
|
|
|
|
test('import accepts a dense multibyte payload just under the byte cap', () => {
|
|
// Many small multibyte entries packed deterministically 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({
|
|
id: 'r1',
|
|
occurredAt: '2026-08-19T08:30:00.000Z',
|
|
bristolType: 2,
|
|
color: 'green',
|
|
urgency: 3,
|
|
discomfort: 2,
|
|
note: 'rough morning',
|
|
provenance: { origin: 'ai-suggestion', apiToken: 'sk-leaked-token' },
|
|
}),
|
|
{
|
|
id: 'raw-2', bristolType: 9, color: 'chartreuse', urgency: 11, discomfort: -4,
|
|
note: 'odd shape', symptoms: { blood: true }, sessionCookie: 'SID=hijack',
|
|
},
|
|
];
|
|
const exported = exportLedger(saved, '2026-08-20T00:00:00.000Z');
|
|
assert.doesNotMatch(exported, /sk-leaked-token|SID=hijack|chartreuse/);
|
|
const roundTripped = importLedger(exported);
|
|
assert.equal(roundTripped.length, 2);
|
|
assert.deepEqual(
|
|
{ id: roundTripped[0].id, occurredAt: roundTripped[0].occurredAt, bristolType: roundTripped[0].bristolType, color: roundTripped[0].color, urgency: roundTripped[0].urgency, discomfort: roundTripped[0].discomfort, note: roundTripped[0].note },
|
|
{ id: 'r1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'rough morning' },
|
|
);
|
|
assert.equal(roundTripped[0].symptoms.blood, false);
|
|
assert.deepEqual(roundTripped[0].provenance, { origin: 'ai-suggestion' });
|
|
assert.equal(roundTripped[1].bristolType, 4, 'out-of-range Bristol type falls back to the neutral default under strict schema');
|
|
assert.equal(roundTripped[1].color, 'brown');
|
|
assert.equal(roundTripped[1].urgency, 0, 'out-of-range urgency falls back to the neutral default');
|
|
assert.equal(roundTripped[1].discomfort, 0);
|
|
assert.deepEqual(roundTripped[1].provenance, undefined);
|
|
});
|
|
|
|
test('re-exporting an imported ledger converges to the same portable document', () => {
|
|
const entries = [{ id: 'c1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 6, color: 'yellow', urgency: 2, discomfort: 1, note: 'loose', provenance: { origin: 'user' } }];
|
|
const first = JSON.parse(exportLedger(entries, '2026-08-20T00:00:00.000Z'));
|
|
const second = JSON.parse(exportLedger(importLedger(exportLedger(entries, '2026-08-20T00:00:00.000Z')), '2026-08-20T00:00:00.000Z'));
|
|
assert.deepEqual(second, first);
|
|
});
|