Files
Password-Manager/js/tests/csv.test.js
T
r-zakarya 829056f1fa fix(import): normalize timestamps to the DB format at the import door
vault_entries uses SQLite's space-separated UTC format everywhere, and both
sorting and sync last-write-wins compare the strings lexically — so a foreign
JSON import carrying strict-ISO 'T'/millis/Z/offset timestamps would slot in
with a different format and subtly break ordering and merge arbitration.
normalizeImportTimestamp converts any ISO-ish variant to 'YYYY-MM-DD
HH:MM:SS' UTC (bare strings treated as UTC, garbage -> '' = server stamps
now). +1 unit test (69 total).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:13:41 +01:00

129 lines
5.4 KiB
JavaScript

// 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);
});
// --- Import timestamp normalization (single door into vault_entries) --------
test('normalizeImportTimestamp: every ISO-ish variant lands in DB space-format UTC', () => {
// Our own export (DB format, bare UTC) — unchanged.
assert.equal(T.normalizeImportTimestamp('2026-07-10 15:56:23'), '2026-07-10 15:56:23');
// Strict ISO with T + millis + Z — same instant, space format.
assert.equal(T.normalizeImportTimestamp('2026-07-10T15:56:23.123Z'), '2026-07-10 15:56:23');
// Explicit offset is converted to UTC.
assert.equal(T.normalizeImportTimestamp('2026-07-10T17:56:23+02:00'), '2026-07-10 15:56:23');
// Garbage / absent → '' (server stamps "now").
assert.equal(T.normalizeImportTimestamp('not-a-date'), '');
assert.equal(T.normalizeImportTimestamp(''), '');
});