timmy-talking-turd/tests/ledger-portability.acceptance.mjs
Timmy dd86d6675d
Some checks failed
Quality gates / quality (pull_request) Failing after 1m28s
fix: close hostile-review blockers in ledger portability
- 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.
2026-08-22 21:53:08 +00:00

214 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
const ROOT_STORE = 'timmy:/:ledger-v1';
const LEGACY_STORE = 'timmy-ledger-v1';
await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => {
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, serviceWorkers: 'block' });
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));
page.on('dialog', dialog => dialog.accept());
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
// Seed one confirmed, user-owned entry with provenance.
await page.evaluate(() => {
localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([
{
id: 'seed-1', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2, color: 'green',
urgency: 3, discomfort: 2, note: 'seeded confirmed entry', photoDataUrl: '',
symptoms: { blood: false }, provenance: { origin: 'ai-suggestion' },
},
]));
});
await page.reload({ waitUntil: 'networkidle' });
await page.locator('[data-view="calendar"]').last().click();
assert.equal(await page.locator('.entry').count(), 1, 'seeded entry renders');
// Export writes the versioned envelope with provenance and no secrets.
await page.locator('[data-view="calendar"]').last().click();
await page.locator('[data-view="privacy"]').click();
const downloadPromise = page.waitForEvent('download');
await page.locator('#export').click();
const download = await downloadPromise;
const exportedText = await download.path().then(readFile).then(buffer => buffer.toString('utf8'));
const exported = JSON.parse(exportedText);
assert.equal(exported.schemaVersion, 1);
assert.equal(exported.entries[0].provenance.origin, 'ai-suggestion');
assert.doesNotMatch(exportedText, /apiToken|sessionId|sk-/i);
// Importing a prior-version export merges instead of replacing user data.
const legacyPath = join(workDir, 'legacy-ledger.json');
await writeFile(legacyPath, JSON.stringify([
{ id: 'legacy-9', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'legacy import' },
]));
await page.setInputFiles('#import', legacyPath);
await page.getByText('Ledger imported').waitFor({ timeout: 5000 });
const mergedIds = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(mergedIds.sort(), ['legacy-9', 'seed-1'], 'import must merge, not replace');
// A future schema version fails safely and leaves the ledger untouched.
const futurePath = join(workDir, 'future-ledger.json');
await writeFile(futurePath, JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 2, entries: [{ id: 'from-the-future' }] }));
await page.setInputFiles('#import', futurePath);
await page.getByText(/newer Timmy app/).waitFor({ timeout: 5000 });
const afterFuture = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterFuture.sort(), ['legacy-9', 'seed-1'], 'failed import must not mutate the ledger');
// Malformed JSON fails safely and leaves the ledger untouched.
const malformedPath = join(workDir, 'malformed-ledger.json');
await writeFile(malformedPath, '{"schemaVersion":1,"entries":');
await page.setInputFiles('#import', malformedPath);
await page.getByText(/not a supported Timmy export/).waitFor({ timeout: 5000 });
const afterMalformed = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
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 });
const storesAfterDelete = await page.evaluate(([rootStore, legacyStore]) => ({
root: localStorage.getItem(rootStore),
legacy: localStorage.getItem(legacyStore),
}), [ROOT_STORE, LEGACY_STORE]);
assert.equal(storesAfterDelete.root, null, 'namespaced store cleared');
assert.equal(storesAfterDelete.legacy, null, 'legacy store cleared');
await page.locator('[data-view="calendar"]').last().click();
assert.equal(await page.locator('.entry').count(), 0, 'journal is empty after delete-all');
// Base-path deployments keep their ledger in an isolated namespace.
const staging = spawn(process.execPath, ['server.mjs'], {
env: { ...process.env, PORT: '4179', HOST: '127.0.0.1', TIMMY_BASE_PATH: '/timmy-staging' },
stdio: 'ignore',
});
try {
let up = false;
for (let attempt = 0; attempt < 40 && !up; attempt += 1) {
up = await fetch('http://127.0.0.1:4179/timmy-staging/api/healthz').then(response => response.ok).catch(() => false);
if (!up) await sleep(250);
}
assert.ok(up, 'staging server must start');
const stagingContext = await browser.newContext({ viewport: { width: 390, height: 844 }, serviceWorkers: 'block' });
const stagingPage = await stagingContext.newPage();
stagingPage.on('dialog', dialog => dialog.accept());
await stagingPage.goto('http://127.0.0.1:4179/timmy-staging', { waitUntil: 'networkidle' });
await stagingPage.evaluate(() => {
localStorage.clear();
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();
assert.equal(await stagingPage.locator('.entry').count(), 1, 'staging entry renders under its base path');
const isolation = await stagingPage.evaluate(() => ({
staging: localStorage.getItem('timmy:/timmy-staging:ledger-v1'),
root: localStorage.getItem('timmy:/:ledger-v1'),
legacy: localStorage.getItem('timmy-ledger-v1'),
}));
assert.ok(isolation.staging, 'staging store keeps its data');
assert.equal(isolation.root, null, 'root-namespaced 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 namespaces global legacy ledger',
);
await stagingContext.close();
} finally {
staging.kill();
}
assert.deepEqual(errors, [], 'no console or page errors');
await context.close();
console.log('PASS ledger portability: export round trip, merge import, safe failures, isolation, delete-all');
} finally {
await browser.close();
await rm(workDir, { recursive: true, force: true });
}
});