d9397881dc
Two CODE_AUDIT items in one session. §3.2 — Frontend regression net (js/tests/, 35 tests, node:test, zero deps): - harness.js loads app.js (monofile, no exports) into a node:vm with browser globals stubbed, surfacing internals via an export epilogue. - crypto: deriveKeyAndVerifier (AES key == raw PBKDF2, cross-checked vs Node pbkdf2Sync), legacy-vs-v2 verifier decoupling, encrypt/decrypt round-trip, IV uniqueness, AEAD tamper/wrong-key. - csv: parseCSV tokenizer, findColumn heuristics, Bitwarden/KeePass mapping. - merge: applyRemoteSnapshot add/update/skip (LWW), tombstone delete, resurrection arbitration (both NaN branches), local-tombstone veto, additive folder merge. Only api() is stubbed; loadEntries/encryptImportEntry run for real. - Wired as a build gate in BuildAssets.ps1 (after node --check, bypass PM_SKIP_TESTS=1). §2.2 — Unify timestamps on UTC: - Entry created_at/updated_at were written via Delphi FormatDateTime(Now) = LOCAL, while deleted_at/tombstones use SQLite CURRENT_TIMESTAMP = UTC. The tombstone-resurrection arbitration compared the two zones, skewing by the machine's UTC offset even single-device. - Add NowUTC/NowUTCStr to PM.Database, swap in at every entry/attachment write site (Entries create/update/bulk, Attachments POST echo). - No JS change needed: arbitration now compares same-zone values. - Existing rows self-heal on next edit (no destructive migration). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
240 lines
10 KiB
JavaScript
240 lines
10 KiB
JavaScript
// Sync merge / resurrection-arbitration tests — the highest-risk logic in
|
|
// applyRemoteSnapshot (CODE_AUDIT §2.2). We run the REAL merge function
|
|
// against an in-memory fake WebDAV/DB by stubbing only the `api()` seam;
|
|
// loadEntries/loadFolders/encryptImportEntry all run for real (with a real
|
|
// AES key), so this exercises the full pull→merge path, not a re-implementation.
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { loadApp } = require('./harness.js');
|
|
|
|
// Build a fresh app context wired to an in-memory fake server. Returns
|
|
// { ctx, T, db, install } — call install() to point ctx.api at the fake.
|
|
async function freshMerge() {
|
|
const ctx = loadApp();
|
|
const T = ctx.__test;
|
|
|
|
// Real vault key so encryptImportEntry (called for POST/PUT) works.
|
|
const { cryptoKey } = await T.deriveKeyAndVerifier('master-pw', 'saltsalt', 100000, T.HASH_ALGO_V2);
|
|
T.state.cryptoKey = cryptoKey;
|
|
T.state.token = 'tok';
|
|
T.state.username = 'alice';
|
|
|
|
// Fake persistent store. Entries keyed by uuid; ids auto-increment.
|
|
const db = {
|
|
entries: [], // { id, uuid, updated_at, site, ... }
|
|
tombstones: [], // { uuid, deleted_at }
|
|
folders: [], // { name, color, icon }
|
|
_nextId: 1,
|
|
seedEntry(e) {
|
|
const row = Object.assign({ id: this._nextId++, updated_at: '', kind: 'login' }, e);
|
|
this.entries.push(row);
|
|
return row;
|
|
},
|
|
};
|
|
|
|
// Minimal router mirroring the endpoints applyRemoteSnapshot touches.
|
|
async function fakeApi(path, opts) {
|
|
opts = opts || {};
|
|
const method = (opts.method || 'GET').toUpperCase();
|
|
const body = opts.body ? JSON.parse(opts.body) : null;
|
|
|
|
if (path === '/entries' && method === 'GET') {
|
|
return db.entries.map(e => Object.assign({}, e));
|
|
}
|
|
if (path === '/folders' && method === 'GET') {
|
|
return db.folders.map(f => Object.assign({}, f));
|
|
}
|
|
if (path === '/folders' && method === 'POST') {
|
|
db.folders.push({ name: body.name, color: body.color || '', icon: body.icon || '' });
|
|
return { ok: true };
|
|
}
|
|
if (path === '/entries/tombstones' && method === 'GET') {
|
|
return db.tombstones.map(t => Object.assign({}, t));
|
|
}
|
|
if (path === '/entries/tombstones' && method === 'POST') {
|
|
for (const uuid of (body.uuids || [])) {
|
|
// Hard-delete matching live rows + record the tombstone.
|
|
db.entries = db.entries.filter(e => e.uuid !== uuid);
|
|
if (!db.tombstones.some(t => t.uuid === uuid))
|
|
db.tombstones.push({ uuid, deleted_at: new Date().toISOString() });
|
|
}
|
|
return { ok: true };
|
|
}
|
|
if (path === '/entries' && method === 'POST') {
|
|
// Purge any tombstone for this uuid (CLAUDE.md: purge-on-insert).
|
|
db.tombstones = db.tombstones.filter(t => t.uuid !== body.uuid);
|
|
const row = Object.assign({ id: db._nextId++ }, body);
|
|
// Carry updated_at from the remote snapshot if the merge preserved
|
|
// it; encryptImportEntry drops it, so default to "now".
|
|
if (!row.updated_at) row.updated_at = new Date().toISOString();
|
|
db.entries.push(row);
|
|
return { id: row.id };
|
|
}
|
|
const mPut = path.match(/^\/entries\/(\d+)$/);
|
|
if (mPut && method === 'PUT') {
|
|
const id = parseInt(mPut[1], 10);
|
|
const row = db.entries.find(e => e.id === id);
|
|
if (row) Object.assign(row, body);
|
|
return { ok: true };
|
|
}
|
|
const mAtt = path.match(/^\/entries\/(\d+)\/attachments$/);
|
|
if (mAtt && method === 'GET') return [];
|
|
if (mAtt && method === 'POST') return { ok: true };
|
|
|
|
throw new Error('fakeApi: unhandled ' + method + ' ' + path);
|
|
}
|
|
|
|
ctx.api = fakeApi;
|
|
return { ctx, T, db };
|
|
}
|
|
|
|
const remoteEntry = (o) => Object.assign({
|
|
uuid: '', site: 'https://x', title: '', username: 'u', password: 'p',
|
|
folder: 'All', tags: [], favorite: false, totp_secret: '', kind: 'login',
|
|
template: '', custom_fields: [], attachments: [], icon_b64: '',
|
|
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
|
}, o);
|
|
|
|
test('merge: remote-only entry is added locally, keeping its uuid', async () => {
|
|
const { T, db } = await freshMerge();
|
|
const res = await T.applyRemoteSnapshot({
|
|
entries: [remoteEntry({ uuid: 'uuid-new', site: 'https://new.example' })],
|
|
tombstones: [], folders: [],
|
|
});
|
|
assert.equal(res.added, 1);
|
|
assert.equal(res.updated, 0);
|
|
assert.equal(db.entries.length, 1);
|
|
assert.equal(db.entries[0].uuid, 'uuid-new');
|
|
assert.equal(db.entries[0].site, 'https://new.example');
|
|
});
|
|
|
|
test('merge: remote entry newer than local → PUT updates it', async () => {
|
|
const { T, db } = await freshMerge();
|
|
db.seedEntry({ uuid: 'u1', site: 'https://old', updated_at: '2026-01-01T00:00:00Z' });
|
|
const res = await T.applyRemoteSnapshot({
|
|
entries: [remoteEntry({ uuid: 'u1', site: 'https://newer', updated_at: '2026-06-01T00:00:00Z' })],
|
|
tombstones: [], folders: [],
|
|
});
|
|
assert.equal(res.updated, 1);
|
|
assert.equal(res.added, 0);
|
|
assert.equal(db.entries[0].site, 'https://newer');
|
|
});
|
|
|
|
test('merge: remote entry OLDER than local → skipped (last-write-wins keeps local)', async () => {
|
|
const { T, db } = await freshMerge();
|
|
db.seedEntry({ uuid: 'u1', site: 'https://local-wins', updated_at: '2026-06-01T00:00:00Z' });
|
|
const res = await T.applyRemoteSnapshot({
|
|
entries: [remoteEntry({ uuid: 'u1', site: 'https://stale', updated_at: '2026-01-01T00:00:00Z' })],
|
|
tombstones: [], folders: [],
|
|
});
|
|
assert.equal(res.updated, 0);
|
|
assert.equal(db.entries[0].site, 'https://local-wins');
|
|
});
|
|
|
|
test('merge: remote tombstone deletes an untouched local entry', async () => {
|
|
const { T, db } = await freshMerge();
|
|
db.seedEntry({ uuid: 'u1', updated_at: '2026-01-01T00:00:00Z' });
|
|
const res = await T.applyRemoteSnapshot({
|
|
entries: [],
|
|
tombstones: [{ uuid: 'u1', deleted_at: '2026-06-01T00:00:00Z' }],
|
|
folders: [],
|
|
});
|
|
assert.equal(res.deleted, 1);
|
|
assert.equal(db.entries.length, 0);
|
|
assert.ok(db.tombstones.some(t => t.uuid === 'u1'));
|
|
});
|
|
|
|
test('merge: RESURRECTION — local edit newer than tombstone survives the delete', async () => {
|
|
const { T, db } = await freshMerge();
|
|
// Local entry restored/edited AFTER the remote deletion timestamp.
|
|
db.seedEntry({ uuid: 'u1', site: 'https://restored', updated_at: '2026-06-02T00:00:00Z' });
|
|
const res = await T.applyRemoteSnapshot({
|
|
entries: [],
|
|
tombstones: [{ uuid: 'u1', deleted_at: '2026-06-01T00:00:00Z' }],
|
|
folders: [],
|
|
});
|
|
assert.equal(res.deleted, 0, 'a newer local edit must beat the tombstone');
|
|
assert.equal(db.entries.length, 1, 'resurrected entry stays');
|
|
assert.equal(db.entries[0].site, 'https://restored');
|
|
});
|
|
|
|
test('merge: unparseable LOCAL updated_at favours KEEP (resurrection wins)', async () => {
|
|
// If we can't parse the local timestamp we can't prove the entry is older
|
|
// than the deletion → keep it (data-loss is worse than a stale row).
|
|
const { T, db } = await freshMerge();
|
|
db.seedEntry({ uuid: 'u1', updated_at: 'garbage-timestamp' });
|
|
const res = await T.applyRemoteSnapshot({
|
|
entries: [],
|
|
tombstones: [{ uuid: 'u1', deleted_at: '2026-06-01T00:00:00Z' }],
|
|
folders: [],
|
|
});
|
|
assert.equal(res.deleted, 0, 'unparseable local updated_at → keep');
|
|
assert.equal(db.entries.length, 1);
|
|
});
|
|
|
|
test('merge: unparseable remote deleted_at → delete IS applied (delete-intent honored)', async () => {
|
|
// Documents the deliberate asymmetry: a tombstone with an unknown time
|
|
// still expresses delete intent, and the local side has a parseable
|
|
// updated_at that it can't prove is newer, so the delete goes through.
|
|
// (In production deleted_at is always server-formatted → parseable; this
|
|
// is the corrupted-snapshot edge.)
|
|
const { T, db } = await freshMerge();
|
|
db.seedEntry({ uuid: 'u1', updated_at: '2026-01-01T00:00:00Z' });
|
|
const res = await T.applyRemoteSnapshot({
|
|
entries: [],
|
|
tombstones: [{ uuid: 'u1', deleted_at: 'not-a-date' }],
|
|
folders: [],
|
|
});
|
|
assert.equal(res.deleted, 1, 'unparseable deleted_at + parseable local → apply delete');
|
|
assert.equal(db.entries.length, 0);
|
|
});
|
|
|
|
test('merge: local tombstone vetoes a remote entry with the same uuid', async () => {
|
|
const { T, db } = await freshMerge();
|
|
// This device already hard-deleted u1 (tombstone present, no live row).
|
|
db.tombstones.push({ uuid: 'u1', deleted_at: '2026-06-01T00:00:00Z' });
|
|
const res = await T.applyRemoteSnapshot({
|
|
entries: [remoteEntry({ uuid: 'u1', site: 'https://should-not-resurrect' })],
|
|
tombstones: [], folders: [],
|
|
});
|
|
assert.equal(res.added, 0, 'local tombstone must veto the remote entry');
|
|
assert.equal(db.entries.length, 0);
|
|
});
|
|
|
|
test('merge: missing remote folders are added additively', async () => {
|
|
const { T, db } = await freshMerge();
|
|
db.folders.push({ name: 'Existing', color: '#111', icon: '' });
|
|
await T.applyRemoteSnapshot({
|
|
entries: [],
|
|
tombstones: [],
|
|
folders: [
|
|
{ name: 'Existing', color: '#999', icon: '' }, // must NOT overwrite
|
|
{ name: 'FromRemote', color: '#0f0', icon: 'star' },
|
|
],
|
|
});
|
|
const existing = db.folders.find(f => f.name === 'Existing');
|
|
const added = db.folders.find(f => f.name === 'FromRemote');
|
|
assert.equal(existing.color, '#111', 'existing folder customisation is preserved');
|
|
assert.ok(added, 'new remote folder is created');
|
|
assert.equal(added.color, '#0f0');
|
|
});
|
|
|
|
test('merge: empty/invalid remote snapshot is a no-op', async () => {
|
|
const { T, db } = await freshMerge();
|
|
db.seedEntry({ uuid: 'u1' });
|
|
const res = await T.applyRemoteSnapshot(null);
|
|
assert.deepEqual({ ...res }, { added: 0, updated: 0, deleted: 0, failed: 0 });
|
|
assert.equal(db.entries.length, 1);
|
|
});
|
|
|
|
test('merge: entries without a uuid are ignored (no ghost rows)', async () => {
|
|
const { T, db } = await freshMerge();
|
|
const res = await T.applyRemoteSnapshot({
|
|
entries: [remoteEntry({ uuid: '' })],
|
|
tombstones: [], folders: [],
|
|
});
|
|
assert.equal(res.added, 0);
|
|
assert.equal(db.entries.length, 0);
|
|
});
|