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.
214 lines
13 KiB
JavaScript
214 lines
13 KiB
JavaScript
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 namespace’s 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 });
|
||
}
|
||
});
|