Quality gates / quality (pull_request) Successful in 18m50s
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
150 lines
6.3 KiB
JavaScript
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);
|
|
});
|