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>
This commit is contained in:
+16
-2
@@ -425,6 +425,20 @@ function parseEntriesFromJSON(text) {
|
|||||||
// Encrypt one parsed entry (plaintext password + optional TOTP) into the
|
// Encrypt one parsed entry (plaintext password + optional TOTP) into the
|
||||||
// shape the bulk-import endpoint expects. Reuses encryptPwd which already
|
// shape the bulk-import endpoint expects. Reuses encryptPwd which already
|
||||||
// generates a fresh IV per call.
|
// 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) {
|
async function encryptImportEntry(plain) {
|
||||||
const pw = await encryptPwd(plain.password);
|
const pw = await encryptPwd(plain.password);
|
||||||
let totpEnc = '', totpIv = '';
|
let totpEnc = '', totpIv = '';
|
||||||
@@ -476,8 +490,8 @@ async function encryptImportEntry(plain) {
|
|||||||
template: plain.template || '',
|
template: plain.template || '',
|
||||||
// Preserve original timestamps on restore — bulk-import falls back
|
// Preserve original timestamps on restore — bulk-import falls back
|
||||||
// to now only when these are absent (foreign CSV imports).
|
// to now only when these are absent (foreign CSV imports).
|
||||||
created_at: plain.created_at || '',
|
created_at: normalizeImportTimestamp(plain.created_at),
|
||||||
updated_at: plain.updated_at || '',
|
updated_at: normalizeImportTimestamp(plain.updated_at),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,3 +112,17 @@ test('parseEntriesFromCSV: throws when no header + data rows', () => {
|
|||||||
test('parseEntriesFromCSV: throws when no title/url/username column present', () => {
|
test('parseEntriesFromCSV: throws when no title/url/username column present', () => {
|
||||||
assert.throws(() => T.parseEntriesFromCSV('password,foo\npw,x'), /title\/url or username/i);
|
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(''), '');
|
||||||
|
});
|
||||||
|
|||||||
+2
-2
@@ -146,8 +146,8 @@ function loadApp(overrides = {}) {
|
|||||||
base32Decode, generateTOTP, parseOtpAuthUri,
|
base32Decode, generateTOTP, parseOtpAuthUri,
|
||||||
// favicon
|
// favicon
|
||||||
faviconHost,
|
faviconHost,
|
||||||
// csv
|
// csv / import
|
||||||
parseCSV, findColumn, parseEntriesFromCSV,
|
parseCSV, findColumn, parseEntriesFromCSV, normalizeImportTimestamp,
|
||||||
// strength
|
// strength
|
||||||
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
|
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
|
||||||
// merge (async, coupled — tests stub the io seams below)
|
// merge (async, coupled — tests stub the io seams below)
|
||||||
|
|||||||
Reference in New Issue
Block a user