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>
Frontend unit tests
Regression net for the highest-risk pure/near-pure logic in js/app.js:
crypto round-trip, KDF/verifier derivation, CSV import parsing, and the
sync-merge / tombstone-resurrection arbitration. Addresses CODE_AUDIT.md
§3.2 (aucun test automatisé).
Running
npm test
# or directly:
node --test "js/tests/**/*.test.js"
Zero dependencies — uses the Node built-in test runner (node:test) and
webcrypto. Requires Node ≥ 18 (developed on v24). Runs in ~1.7 s.
How it works — harness.js
The frontend is a large browser script with no module exports and one
top-level side effect (a DOMContentLoaded listener). It's being split into
ordered classic-script files (§3.1); the harness concatenates the app
parts in load order (APP_PARTS = app.crypto.js + app.js) into one
source — node:vm doesn't share top-level const/let across separate
runInContext calls the way the browser shares them across <script> tags.
argon2.js is a self-contained IIFE and loads separately first. The
concatenated source runs in a node:vm context with browser globals stubbed
(crypto, localStorage, document, location, …) so init() never
fires, then an export epilogue surfaces the internals on globalThis.__test.
Two gotchas the harness works around, both documented inline:
const/letdon't attach to the vm global. Top-levelfunction/vardeclarations become properties of the context global, butconst state,const HASH_ALGO_V2, etc. do not — hence the explicit export epilogue.- Cross-realm prototypes. Values returned from the sandbox carry the
sandbox realm's prototypes, so
assert.deepStrictEqualtrips on the prototype check. Structural comparisons normalize through JSON first (seeeqDeepincsv.test.js).
The merge tests stub only the api() seam (a function declaration →
overridable global property) with an in-memory fake server; loadEntries,
loadFolders, and encryptImportEntry all run for real against it — so the
tests exercise the actual pull→merge path, not a re-implementation.
Suites
| File | Covers |
|---|---|
crypto.test.js |
deriveKeyAndVerifier (AES key == raw PBKDF2, cross-checked vs Node's pbkdf2Sync), legacy vs -v2 verifier decoupling, encryptPwd/decryptPwd round-trip, IV uniqueness, AEAD tamper/wrong-key → [ERROR] |
csv.test.js |
parseCSV tokenizer (quotes, escaped "", CRLF, trailing field), findColumn header heuristics, parseEntriesFromCSV for Bitwarden/KeePass shapes, note-vs-login classification |
merge.test.js |
applyRemoteSnapshot: add/update/skip (last-write-wins), tombstone delete, resurrection arbitration (both NaN branches), local-tombstone veto, additive folder merge |
Notes on encoded behavior
merge.test.js locks in one deliberately-asymmetric behavior: an unparseable
local updated_at favours KEEP (resurrection wins), but an unparseable
remote deleted_at still applies the delete. In production deleted_at
is always server-formatted (parseable), so this only matters for a corrupted
remote snapshot. If that arbitration is ever changed, the two
unparseable … tests are where to update the expectation.