From 829056f1facb4f3fb31a84296e8653beb25e90a6 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:13:41 +0100 Subject: [PATCH] fix(import): normalize timestamps to the DB format at the import door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- js/app.import.js | 18 ++++++++++++++++-- js/tests/csv.test.js | 14 ++++++++++++++ js/tests/harness.js | 4 ++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/js/app.import.js b/js/app.import.js index 7fe8958..5d1a8fd 100644 --- a/js/app.import.js +++ b/js/app.import.js @@ -425,6 +425,20 @@ function parseEntriesFromJSON(text) { // Encrypt one parsed entry (plaintext password + optional TOTP) into the // shape the bulk-import endpoint expects. Reuses encryptPwd which already // generates a fresh IV per call. +// Normalize any ISO-ish timestamp to the DB's 'YYYY-MM-DD HH:MM:SS' (UTC). +// Imports are the ONE door where a foreign format (T separator, millis, Z, +// offset) could enter vault_entries — and sorting + sync last-write-wins +// compare these strings LEXICALLY, so a mixed format breaks both. Bare +// strings are treated as UTC (our own exports carry UTC without a marker). +function normalizeImportTimestamp(s) { + if (!s) return ''; + s = String(s).trim(); + const d = new Date(s.replace(' ', 'T') + + (/(Z|[+-]\d\d:?\d\d)$/.test(s) ? '' : 'Z')); + if (isNaN(d)) return ''; + return d.toISOString().slice(0, 19).replace('T', ' '); +} + async function encryptImportEntry(plain) { const pw = await encryptPwd(plain.password); let totpEnc = '', totpIv = ''; @@ -476,8 +490,8 @@ async function encryptImportEntry(plain) { template: plain.template || '', // Preserve original timestamps on restore — bulk-import falls back // to now only when these are absent (foreign CSV imports). - created_at: plain.created_at || '', - updated_at: plain.updated_at || '', + created_at: normalizeImportTimestamp(plain.created_at), + updated_at: normalizeImportTimestamp(plain.updated_at), }); } diff --git a/js/tests/csv.test.js b/js/tests/csv.test.js index baa4968..b12c312 100644 --- a/js/tests/csv.test.js +++ b/js/tests/csv.test.js @@ -112,3 +112,17 @@ test('parseEntriesFromCSV: throws when no header + data rows', () => { 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(''), ''); +}); diff --git a/js/tests/harness.js b/js/tests/harness.js index cb377b9..fdad35f 100644 --- a/js/tests/harness.js +++ b/js/tests/harness.js @@ -146,8 +146,8 @@ function loadApp(overrides = {}) { base32Decode, generateTOTP, parseOtpAuthUri, // favicon faviconHost, - // csv - parseCSV, findColumn, parseEntriesFromCSV, + // csv / import + parseCSV, findColumn, parseEntriesFromCSV, normalizeImportTimestamp, // strength computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined), // merge (async, coupled — tests stub the io seams below)