test: add frontend unit suite + fix mixed local/UTC timestamps

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>
This commit is contained in:
r-zakarya
2026-07-04 19:17:33 +01:00
parent 73e4e37f19
commit d9397881dc
12 changed files with 818 additions and 26 deletions
+114
View File
@@ -0,0 +1,114 @@
// CSV import parsing tests — parseCSV (RFC-ish tokenizer), findColumn
// (header heuristics), and parseEntriesFromCSV (multi-format mapping).
// These are pure functions with no state/DOM coupling.
const test = require('node:test');
const assert = require('node:assert/strict');
const { loadApp } = require('./harness.js');
const T = loadApp().__test;
// Values returned from the vm sandbox carry the sandbox realm's prototypes,
// so deepStrictEqual's prototype check trips. Normalize through JSON to
// compare by structure (fine for plain data).
const eqDeep = (actual, expected, msg) =>
assert.deepEqual(JSON.parse(JSON.stringify(actual)), expected, msg);
test('parseCSV: simple rows', () => {
eqDeep(T.parseCSV('a,b,c\n1,2,3'), [['a', 'b', 'c'], ['1', '2', '3']]);
});
test('parseCSV: quoted fields with commas and newlines', () => {
const rows = T.parseCSV('name,note\n"Smith, John","line1\nline2"');
eqDeep(rows, [['name', 'note'], ['Smith, John', 'line1\nline2']]);
});
test('parseCSV: escaped double-quotes ("")', () => {
const rows = T.parseCSV('v\n"say ""hi"""');
eqDeep(rows, [['v'], ['say "hi"']]);
});
test('parseCSV: CRLF line endings', () => {
eqDeep(T.parseCSV('a,b\r\n1,2\r\n'), [['a', 'b'], ['1', '2']]);
});
test('parseCSV: trailing field with no final newline', () => {
eqDeep(T.parseCSV('a,b\n1,2'), [['a', 'b'], ['1', '2']]);
});
test('parseCSV: blank lines are dropped', () => {
eqDeep(T.parseCSV('a,b\n\n1,2\n'), [['a', 'b'], ['1', '2']]);
});
test('findColumn: case/underscore/dash-insensitive matching', () => {
const headers = ['Login_URI', 'User Name', 'PASSWORD'];
assert.equal(T.findColumn(headers, ['login_uri']), 0);
assert.equal(T.findColumn(headers, ['username', 'user_name']), 1);
assert.equal(T.findColumn(headers, ['password']), 2);
assert.equal(T.findColumn(headers, ['nope']), null);
});
test('parseEntriesFromCSV: Bitwarden-style header maps site/user/pwd/totp', () => {
const csv = [
'folder,name,login_uri,login_username,login_password,login_totp,notes',
'Work,GitHub,https://github.com,octocat,s3cret,JBSWY3DPEHPK3PXP,hello',
].join('\n');
const { entries, skipped } = T.parseEntriesFromCSV(csv);
assert.equal(skipped, 0);
assert.equal(entries.length, 1);
const e = entries[0];
assert.equal(e.title, 'GitHub');
assert.equal(e.site, 'https://github.com');
assert.equal(e.username, 'octocat');
assert.equal(e.password, 's3cret');
assert.equal(e.totp_secret, 'JBSWY3DPEHPK3PXP');
assert.equal(e.folder, 'Work');
});
test('parseEntriesFromCSV: KeePass-style header (Title/URL/Username/Password/Group)', () => {
const csv = [
'Title,URL,Username,Password,Group,Notes',
'Bank,https://bank.example,alice,pw123,Finance,note',
].join('\n');
const { entries } = T.parseEntriesFromCSV(csv);
assert.equal(entries[0].title, 'Bank');
assert.equal(entries[0].site, 'https://bank.example');
assert.equal(entries[0].username, 'alice');
assert.equal(entries[0].folder, 'Finance');
});
test('parseEntriesFromCSV: note heuristic — empty site+pwd but notes present → kind=note', () => {
const csv = [
'name,url,username,password,notes',
'My Note,,,,"just some text"',
].join('\n');
const { entries } = T.parseEntriesFromCSV(csv);
assert.equal(entries.length, 1);
assert.equal(entries[0].kind, 'note');
});
test('parseEntriesFromCSV: explicit kind=note wins even with site+password present', () => {
// A row with site + password normally classifies as a login; an explicit
// kind=note must override that (precedence: explicit column > heuristic).
const csv = [
'name,url,username,password,kind',
'Recovery Codes,https://example.com,user,BACKUP-CODES,note',
].join('\n');
const { entries } = T.parseEntriesFromCSV(csv);
assert.equal(entries.length, 1);
assert.equal(entries[0].kind, 'note');
assert.equal(entries[0].site, ''); // notes never carry a site
assert.equal(entries[0].password, 'BACKUP-CODES'); // body preserved
});
test('parseEntriesFromCSV: throws on missing password column', () => {
assert.throws(() => T.parseEntriesFromCSV('name,url\nfoo,bar'), /password column/i);
});
test('parseEntriesFromCSV: throws when no header + data rows', () => {
assert.throws(() => T.parseEntriesFromCSV('name,password'), /header row and at least one data row/i);
});
test('parseEntriesFromCSV: throws when no title/url/username column present', () => {
assert.throws(() => T.parseEntriesFromCSV('password,foo\npw,x'), /title\/url or username/i);
});