Files
timmy-talking-turd/tests/portability.test.js
T
timmy 2094a07d9f
Quality gates / quality (pull_request) Successful in 18m50s
feat(portability): version the backup, migrate older files, and keep AI provenance (#35)
A backup is the person's own record and has to keep working as the AI fields evolve.

- Export stamps schema version 2 and carries the confirmed logs plus the training
  contribution receipts, including withdrawn tombstones; both are written through an
  allowlist so unknown keys and credentials never leave the device.
- Import accepts version 1 and 2. A version 1 file is upgraded, the notice names the
  version it came from, and entries that predate provenance stay null instead of being
  given a source they never recorded.
- A file from a newer Timmy is refused with an update instruction, malformed, oversized
  and over-limit files fail closed, and a refused import leaves the ledger unchanged.
- A log saved from a confirmed or corrected AI suggestion records its provenance
  (source, suggested visible fields, model, confirmedAt) and the entry sheet states it.
- Service worker shell cache v5 -> v6 so installed clients actually receive the change.

Evidence: tests/portability.test.js (7 unit cases), tests/portability.acceptance.mjs
(real browser: v1 upgrade, future refusal with unchanged ledger, provenance recorded,
export v2, round trip), release demo scenes with on-camera caption proof, and the
release builder gate portable_backup_restore.

Refs #35
2026-10-22 11:08:55 +00:00

150 lines
6.3 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { LEDGER_EXPORT_VERSION, LEDGER_IMPORT_LIMITS, exportLedger, exportLedgerBackup, importLedger, importLedgerBackup } from '../src/domain.js';
// Issue #35 — a backup is the person's own record. Provenance is part of what they own: a log that
// was saved from a confirmed or corrected AI suggestion has to come back out of the export saying
// exactly that, instead of silently reverting to looking like a manual entry.
const confirmedByUser = {
id: 'demo-corrected',
occurredAt: '2026-08-18T10:00:00.000Z',
bristolType: 2,
color: 'brown',
urgency: 1,
discomfort: 0,
note: 'synthetic',
photoDataUrl: '',
symptoms: {},
provenance: {
source: 'ai-corrected',
model: 'SmolVLM2-2.2B-Instruct',
suggestedBristolType: 4,
suggestedColor: 'brown',
confirmedAt: '2026-08-18T09:59:00.000Z',
},
};
test('an export stamps the current schema version and keeps confirmed AI provenance', () => {
const parsed = JSON.parse(exportLedger([confirmedByUser], '2026-08-18T11:00:00.000Z'));
assert.equal(LEDGER_EXPORT_VERSION, 2);
assert.equal(parsed.schemaVersion, 2);
assert.equal(parsed.entries.length, 1);
assert.deepEqual(parsed.entries[0].provenance, confirmedByUser.provenance);
assert.equal(parsed.entries[0].bristolType, 2, 'the corrected value stays the saved value');
});
// A version 1 file was written before entries recorded provenance. Importing it must restore the
// confirmed values and say plainly that this backup predates provenance, rather than inventing a
// source for logs that never had one.
const legacyBackup = JSON.stringify({
product: 'Timmy the Talking Turd',
schemaVersion: 1,
exportedAt: '2026-08-01T09:00:00.000Z',
entries: [
{
id: 'legacy-entry',
occurredAt: '2026-08-01T08:00:00.000Z',
bristolType: 4,
color: 'brown',
urgency: 2,
discomfort: 1,
note: '',
photoDataUrl: '',
symptoms: { blood: false },
},
],
});
test('a version 1 backup imports its confirmed values and reports the upgrade', () => {
const result = importLedgerBackup(legacyBackup);
assert.equal(result.fromVersion, 1);
assert.equal(result.migrated, true);
assert.equal(result.entries.length, 1);
assert.equal(result.entries[0].id, 'legacy-entry');
assert.equal(result.entries[0].bristolType, 4);
assert.equal(result.entries[0].provenance, null, 'a pre-provenance backup must not be given a source it never recorded');
assert.match(result.notices.join(' '), /version 1/i);
assert.match(result.notices.join(' '), /upgraded/i);
assert.equal(importLedger(legacyBackup).length, 1, 'the plain entry import still works');
});
// A training contribution receipt is user-owned data too. A backup that restored the journal but
// dropped the receipts would tell someone their opt-in never happened, so version 2 carries them —
// with the withdrawal tombstone intact and nothing else copied out of the device.
const withdrawnReceipt = {
contributionId: 'contrib-1',
entryId: 'demo-corrected',
consentVersion: 'training-consent-v1',
consentedAt: '2026-08-18T10:05:00.000Z',
labels: { bristolType: 2, color: 'brown' },
state: 'withdrawn',
withdrawnAt: '2026-08-18T10:30:00.000Z',
artifacts: [
{ artifact: 'raw-image', contributionId: 'contrib-1', state: 'withdrawn' },
{ artifact: 'confirmed-label', contributionId: 'contrib-1', state: 'withdrawn' },
],
// A crafted file must not be able to hand a credential back to the app.
agentAccessCode: 'leak-me-please',
};
test('a backup carries contribution receipts and their tombstones without copying secrets', () => {
const text = exportLedgerBackup({
entries: [{ ...confirmedByUser, agentAccessCode: 'leak-me-please' }],
contributions: [withdrawnReceipt],
exportedAt: '2026-08-18T12:00:00.000Z',
});
const parsed = JSON.parse(text);
assert.equal(parsed.schemaVersion, 2);
assert.equal(parsed.contributions.length, 1);
assert.equal(parsed.contributions[0].state, 'withdrawn');
assert.deepEqual(parsed.contributions[0].artifacts, withdrawnReceipt.artifacts);
assert.equal(parsed.entries[0].agentAccessCode, undefined);
assert.doesNotMatch(text, /leak-me-please/);
const restored = importLedgerBackup(text);
assert.equal(restored.migrated, false);
assert.equal(restored.fromVersion, 2);
assert.equal(restored.contributions.length, 1);
assert.equal(restored.contributions[0].contributionId, 'contrib-1');
assert.equal(restored.contributions[0].state, 'withdrawn');
assert.deepEqual(restored.contributions[0].artifacts, withdrawnReceipt.artifacts);
assert.equal(restored.contributions[0].agentAccessCode, undefined);
assert.deepEqual(restored.entries[0].provenance, confirmedByUser.provenance);
});
test('a backup from a newer Timmy is refused with an instruction instead of a guess', () => {
const future = JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 3, entries: [], contributions: [] });
assert.throws(() => importLedgerBackup(future), /newer version of Timmy/i);
});
test('a file that is not a readable Timmy backup is refused', () => {
assert.throws(() => importLedgerBackup('{not json'), /not readable JSON/i);
assert.throws(() => importLedgerBackup(JSON.stringify({ hello: 'world' })), /not a supported Timmy backup/i);
assert.throws(() => importLedgerBackup(JSON.stringify({ schemaVersion: 0, entries: [] })), /not a supported Timmy backup/i);
});
test('an oversized backup or one with too many logs is refused before anything is imported', () => {
const huge = `{"schemaVersion":2,"entries":[]}${' '.repeat(LEDGER_IMPORT_LIMITS.maxTextLength)}`;
assert.throws(() => importLedgerBackup(huge), /too large/i);
const many = { schemaVersion: 2, entries: Array.from({ length: LEDGER_IMPORT_LIMITS.maxEntries + 1 }, () => ({ id: 'x', bristolType: 4 })) };
assert.throws(() => importLedgerBackup(JSON.stringify(many)), /more logs than Timmy will import/i);
});
test('an unreadable contribution receipt is skipped and reported, never silently dropped', () => {
const text = JSON.stringify({
schemaVersion: 2,
entries: [{ id: 'kept', bristolType: 4 }],
contributions: [{ entryId: 'kept', state: 'queued-local' }],
});
const result = importLedgerBackup(text);
assert.equal(result.contributions.length, 0);
assert.match(result.notices.join(' '), /could not be read and was skipped/i);
});