Files
Password-Manager/js/tests/harness.js
T
r-zakarya f97e44768a fix(desktop): stop pushing a snapshot that changed nothing
Measured on the phone, opening a cached vault: 121 ms to read the sealed file,
14 ms to display it - and then 5718 ms decrypting a snapshot it already had.

The cause is upstream. A sync pushes unconditionally at the end, and the push
is never a no-op: the container is re-encrypted with a fresh salt and IV, so an
identical vault produces completely different bytes. On Gitea that is one more
commit and a new blob sha, so every other device sees a "new" snapshot and pays
a full key derivation to arrive back where it started. It also grew the
repository on every sync that changed nothing, which is the history cost the
plan warns about.

The merged state is now compared against what was pulled, as plaintext -
the only place equality is observable - ignoring snapshot_at, which changes by
construction. Equal means the push is skipped and the sync still counts as done,
timestamp included: having nothing to write is a success, not an absence.

The comparison is deliberately strict. A false negative costs one commit; a
false positive would silence a push that carried a change and lose it for the
other devices. Four tests fix where that line sits, including a modified
updated_at (which arbitrates the merge elsewhere) and a tombstone (a deletion
that has to propagate).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:41:44 +01:00

167 lines
7.2 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');
// The frontend is split into ordered classic-script files (§3.1). In the
// browser they share one global lexical environment; node:vm does NOT share
// top-level const/let across separate runInContext calls, so we CONCATENATE
// the app.* parts (in <script> load order) into one script. argon2.js is a
// self-contained IIFE and loads separately (see below).
const APP_PARTS = ['app.crypto.js', 'app.totp.js', 'app.favicon.js', 'app.import.js', 'app.backup.js', 'app.health.js', 'app.overlays.js', 'app.attachments.js', 'app.autofill.js', 'app.js', 'app.unlock.js', 'app.sync.js'].map(f => path.join(__dirname, '..', f));
// 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);
// Load the vendored Argon2 bundle first (index.html loads it before
// app.js). It assigns globalThis.NobleArgon2 — needed by the argon2id
// KDF branch in deriveKeyBytes.
const ARGON2_JS = path.join(__dirname, '..', 'argon2.js');
vm.runInContext(fs.readFileSync(ARGON2_JS, 'utf8'), sandbox, { filename: 'argon2.js' });
// Concatenate the app.* parts in load order (see APP_PARTS). Newline
// separators keep line-based errors legible; shared global scope is
// preserved because it's a single script run.
let src = APP_PARTS.map(p => fs.readFileSync(p, 'utf8')).join('\n;\n');
// 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,
deriveKeyBytes, isDecoupledVerifierAlgo,
encryptPwd, decryptPwd, sha256Hex,
HASH_ALGO_V2, HASH_ALGO_ARGON2, AUTH_VERIFIER_DOMAIN, ARGON2_DEFAULT_PARAMS,
NobleArgon2: (typeof NobleArgon2 !== 'undefined' ? NobleArgon2 : undefined),
// totp
base32Decode, generateTOTP, parseOtpAuthUri,
// favicon
faviconHost,
// csv / import
parseCSV, findColumn, parseEntriesFromCSV, normalizeImportTimestamp,
// strength
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
// merge (async, coupled — tests stub the io seams below)
applyRemoteSnapshot, buildSyncSnapshot,
// gitea transport
syncIsGitea, giteaUnwrap, syncSnapshotsEqual,
// metadata-at-rest
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
};
`;
vm.runInContext(src, sandbox, { filename: 'app.js' });
return sandbox;
}
module.exports = { loadApp, makeStorage };