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>
143 lines
5.6 KiB
JavaScript
143 lines
5.6 KiB
JavaScript
// ============================================================
|
|
// Test harness — load js/app.js into a sandboxed VM context
|
|
// ============================================================
|
|
//
|
|
// app.js is a ~12k-line browser monofile with no module exports. It runs
|
|
// only one top-level statement (a DOMContentLoaded listener); everything
|
|
// else is function/const declarations. We load it in a node:vm context with
|
|
// browser globals stubbed out so init() never fires, then reach the internals
|
|
// we want to test through an appended export epilogue.
|
|
//
|
|
// Why the epilogue: in vm.runInContext, top-level `function`/`var` declarations
|
|
// attach to the context's global object, but top-level `const`/`let` (like
|
|
// `state`, `API`, `HASH_ALGO_V2`) do NOT. So we append a line that copies the
|
|
// symbols we care about onto globalThis.__test, giving tests a stable handle.
|
|
//
|
|
// Reassignable seams for the merge tests: `api`, `loadEntries`, `loadFolders`,
|
|
// `encryptImportEntry`, etc. are `function` declarations → global properties,
|
|
// so a test can override `ctx.api = fake` and the free-variable lookup inside
|
|
// applyRemoteSnapshot will pick up the fake. `state` is a `const` (lexical),
|
|
// so it can't be replaced — but it CAN be mutated, and applyRemoteSnapshot
|
|
// closes over that same object, so mutating ctx.__test.state is visible to it.
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const { webcrypto } = require('node:crypto');
|
|
|
|
const APP_JS = path.join(__dirname, '..', 'app.js');
|
|
|
|
// In-memory Storage stub (Web Storage API surface used by app.js).
|
|
function makeStorage() {
|
|
const m = new Map();
|
|
return {
|
|
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
|
setItem: (k, v) => { m.set(k, String(v)); },
|
|
removeItem: (k) => { m.delete(k); },
|
|
clear: () => m.clear(),
|
|
key: (i) => Array.from(m.keys())[i] ?? null,
|
|
get length() { return m.size; },
|
|
};
|
|
}
|
|
|
|
// Minimal no-throw DOM/window stubs. app.js only *executes* one DOM call at
|
|
// load (document.addEventListener for DOMContentLoaded) — everything else is
|
|
// inside functions we don't call. So these just have to exist and not throw.
|
|
function makeDomStubs() {
|
|
const noop = () => {};
|
|
const elStub = new Proxy({}, {
|
|
get: (_t, prop) => {
|
|
if (prop === 'style') return {};
|
|
if (prop === 'classList') return { add: noop, remove: noop, toggle: noop, contains: () => false };
|
|
if (prop === 'addEventListener' || prop === 'removeEventListener') return noop;
|
|
if (prop === 'appendChild' || prop === 'append' || prop === 'remove') return noop;
|
|
if (prop === 'setAttribute' || prop === 'removeAttribute') return noop;
|
|
if (prop === 'querySelector') return () => null;
|
|
if (prop === 'querySelectorAll') return () => [];
|
|
return undefined;
|
|
},
|
|
set: () => true,
|
|
});
|
|
const document = {
|
|
addEventListener: noop,
|
|
removeEventListener: noop,
|
|
getElementById: () => null,
|
|
querySelector: () => null,
|
|
querySelectorAll: () => [],
|
|
createElement: () => elStub,
|
|
body: elStub,
|
|
documentElement: elStub,
|
|
};
|
|
return { document, elStub, noop };
|
|
}
|
|
|
|
// Build a fresh sandbox + load app.js into it. Returns the contextified
|
|
// sandbox; test internals live on ctx.__test.
|
|
function loadApp(overrides = {}) {
|
|
const { document, noop } = makeDomStubs();
|
|
|
|
const sandbox = {
|
|
crypto: webcrypto,
|
|
TextEncoder,
|
|
TextDecoder,
|
|
btoa,
|
|
atob,
|
|
console,
|
|
setTimeout,
|
|
clearTimeout,
|
|
setInterval,
|
|
clearInterval,
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Promise,
|
|
URL,
|
|
URLSearchParams,
|
|
// Browser-ish globals used at load time
|
|
location: { pathname: '/index.html', href: 'http://127.0.0.1/index.html', search: '', hash: '' },
|
|
history: { replaceState: noop, pushState: noop },
|
|
navigator: { clipboard: { writeText: async () => {}, readText: async () => '' }, userAgent: 'node-test' },
|
|
localStorage: makeStorage(),
|
|
sessionStorage: makeStorage(),
|
|
document,
|
|
fetch: async () => { throw new Error('fetch not stubbed'); },
|
|
// Some code paths reference matchMedia / requestAnimationFrame
|
|
matchMedia: () => ({ matches: false, addEventListener: noop, addListener: noop }),
|
|
requestAnimationFrame: (cb) => setTimeout(cb, 0),
|
|
...overrides,
|
|
};
|
|
// window / self / globalThis self-reference (app.js reads window.location etc.)
|
|
sandbox.window = sandbox;
|
|
sandbox.self = sandbox;
|
|
sandbox.globalThis = sandbox;
|
|
|
|
vm.createContext(sandbox);
|
|
|
|
let src = fs.readFileSync(APP_JS, 'utf8');
|
|
|
|
// Export epilogue — surface the lexical (const) symbols we test, plus a
|
|
// couple of function-decl seams for convenience. Kept in one place so the
|
|
// list of "what tests can touch" is explicit.
|
|
src += `
|
|
;globalThis.__test = {
|
|
state,
|
|
// crypto
|
|
bytesToHex, hexToBytes: (typeof hexToBytes !== 'undefined' ? hexToBytes : undefined),
|
|
verifierFromKeyHex, deriveKeyAndVerifier, computeVerifier,
|
|
encryptPwd, decryptPwd, sha256Hex,
|
|
HASH_ALGO_V2, AUTH_VERIFIER_DOMAIN,
|
|
// csv
|
|
parseCSV, findColumn, parseEntriesFromCSV,
|
|
// strength
|
|
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
|
|
// merge (async, coupled — tests stub the io seams below)
|
|
applyRemoteSnapshot, buildSyncSnapshot,
|
|
};
|
|
`;
|
|
|
|
vm.runInContext(src, sandbox, { filename: 'app.js' });
|
|
return sandbox;
|
|
}
|
|
|
|
module.exports = { loadApp, makeStorage };
|