// 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 === '/avatar' && method === 'POST') { db.avatar = body.avatar_b64 || ''; 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); // Metadata (site/title/username/tags) is encrypted at rest on the import path, // so a stored row carries _enc + a blank cleartext . Decrypt to check // the value; seeded rows (db.seedEntry) keep cleartext, so fall back to it. async function decField(T, row, field) { const enc = row[field + '_enc'], iv = row[field + '_iv']; if (enc && iv) return await T.decryptPwd(enc, iv); return row[field]; } 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'); // Metadata-at-rest: username is encrypted on the way in — the stored row // carries ciphertext + a blank cleartext field, never the plaintext. assert.equal(db.entries[0].username, '', 'cleartext username must be blanked'); assert.ok(db.entries[0].username_enc, 'username_enc must be present'); assert.ok(db.entries[0].username_iv, 'username_iv must be present'); assert.notEqual(db.entries[0].username_enc, 'u', 'must not store plaintext'); // site is encrypted too: blank cleartext + ciphertext that decrypts back. assert.equal(db.entries[0].site, '', 'cleartext site must be blanked'); assert.ok(db.entries[0].site_enc, 'site_enc must be present'); assert.equal(await decField(T, 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(await decField(T, 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: remote avatar is adopted when the local device has none', async () => { const { ctx, T, db } = await freshMerge(); ctx.__test.state.avatarDataUri = ''; // this device has no picture await T.applyRemoteSnapshot({ entries: [], tombstones: [], folders: [], avatar_b64: 'data:image/jpeg;base64,AAAA', }); assert.equal(db.avatar, 'data:image/jpeg;base64,AAAA', 'avatar POSTed to server'); assert.equal(ctx.__test.state.avatarDataUri, 'data:image/jpeg;base64,AAAA'); }); test('merge: remote avatar does NOT clobber an existing local avatar (additive)', async () => { const { ctx, T, db } = await freshMerge(); ctx.__test.state.avatarDataUri = 'data:image/jpeg;base64,LOCAL'; await T.applyRemoteSnapshot({ entries: [], tombstones: [], folders: [], avatar_b64: 'data:image/jpeg;base64,REMOTE', }); assert.equal(db.avatar, undefined, 'no /avatar POST when a local avatar exists'); assert.equal(ctx.__test.state.avatarDataUri, 'data:image/jpeg;base64,LOCAL'); }); 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, attFailed: 0, folderFailed: 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); });