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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user