keebox 0.0.0-stage → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { confirm, encrypt, decryptField, saveOrReport, nextEntryId, dbPath, log, FIELDS, SECRET_FIELDS } = require("../utils");
4
+ const { parseCsv } = require("../utils/csv");
5
+
6
+ const REQUIRED = ["name", "password"];
7
+
8
+ // csv rows (first = header) -> { records, errors }. Columns are matched by header name (any order, case-insensitive);
9
+ // unknown columns (e.g. "id") are ignored, missing optional ones are empty.
10
+ function rowsToRecords(rows) {
11
+ if (!rows.length) return { records: [], errors: ["file is empty"] };
12
+ const header = rows[0].map((h) => h.trim().toLowerCase());
13
+ const missing = REQUIRED.filter((c) => !header.includes(c));
14
+ if (missing.length) return { records: [], errors: ["missing column(s): " + missing.join(", ") + " (header: " + rows[0].join(",") + ")"] };
15
+
16
+ const records = [];
17
+ const errors = [];
18
+ rows.slice(1).forEach((row, i) => {
19
+ const at = "row " + (i + 1) + ": ";
20
+ if (row.length !== header.length) return errors.push(at + "has " + row.length + " columns, header has " + header.length);
21
+ const rec = {};
22
+ header.forEach((h, j) => (rec[h] = row[j]));
23
+ rec.name = rec.name.trim();
24
+ if (!rec.name) return errors.push(at + "name is empty");
25
+ records.push(rec);
26
+ });
27
+ return { records, errors };
28
+ }
29
+
30
+ // valid date -> ISO string, else null
31
+ function isoDate(value) {
32
+ const t = Date.parse(value);
33
+ return value && !isNaN(t) ? new Date(t).toISOString() : null;
34
+ }
35
+
36
+ // "same account" like `add`: name + url + username + login
37
+ function sameKey(e) {
38
+ return JSON.stringify(["name", "url", "username", "login"].map((f) => e[f] || ""));
39
+ }
40
+
41
+ async function importEntries(db, file, askKey) {
42
+ file = path.resolve(file);
43
+ let rows;
44
+ try {
45
+ rows = parseCsv(fs.readFileSync(file, "utf8"));
46
+ } catch (err) {
47
+ process.exitCode = 1;
48
+ return log.error("Couldn't read " + file + ": " + err.message);
49
+ }
50
+ const { records, errors } = rowsToRecords(rows);
51
+ if (errors.length) {
52
+ process.exitCode = 1;
53
+ errors.forEach((e) => log.problem(e));
54
+ return log.error("Import aborted, nothing changed");
55
+ }
56
+ if (!records.length) return log.warn("No entries in " + file);
57
+
58
+ const key = await askKey();
59
+ if (!key) return;
60
+
61
+ // accounts already in db (login decrypted), plus the ones added from this file
62
+ const seen = new Set();
63
+ for (const e of db.accounts) {
64
+ const login = decryptField(e, "login", key);
65
+ if (login === null) return log.error("Import aborted, fix or delete entry #" + e.id + " first");
66
+ seen.add(sameKey({ ...e, login }));
67
+ }
68
+ const fresh = records.filter((r) => !seen.has(sameKey(r)) && seen.add(sameKey(r)));
69
+ const skipped = records.length - fresh.length;
70
+
71
+ const note = skipped ? " (" + skipped + " already in db, skipped)" : "";
72
+ if (!fresh.length) return log.warn("Nothing to import" + note);
73
+ if (!(await confirm("Import " + fresh.length + " entr" + (fresh.length === 1 ? "y" : "ies") + note))) return;
74
+
75
+ const now = new Date().toISOString();
76
+ let id = nextEntryId(db.accounts);
77
+ for (const r of fresh) {
78
+ const entry = { id: id++ };
79
+ FIELDS.forEach((f) => (entry[f] = r[f] || ""));
80
+ SECRET_FIELDS.forEach((f) => (entry[f] = entry[f] && encrypt(entry[f], key)));
81
+ entry.password = encrypt(r.password, key);
82
+ entry.created = isoDate(r.created) || now;
83
+ entry.updated = isoDate(r.updated) || entry.created;
84
+ db.accounts.push(entry);
85
+ }
86
+ saveOrReport(dbPath, db, "Imported " + fresh.length + " entr" + (fresh.length === 1 ? "y" : "ies") + note, "Error while importing!", "added");
87
+ }
88
+
89
+ module.exports = importEntries;
90
+ module.exports.rowsToRecords = rowsToRecords;
@@ -0,0 +1,20 @@
1
+ const addEntry = require("./add");
2
+ const getEntry = require("./get");
3
+ const updateEntry = require("./update");
4
+ const deleteEntry = require("./delete");
5
+ const exportEntries = require("./export");
6
+ const importEntries = require("./import");
7
+ const checkDb = require("./check");
8
+ const { createRecovery, recoverVault } = require("./recovery");
9
+
10
+ module.exports = {
11
+ addEntry,
12
+ getEntry,
13
+ updateEntry,
14
+ deleteEntry,
15
+ exportEntries,
16
+ importEntries,
17
+ checkDb,
18
+ createRecovery,
19
+ recoverVault,
20
+ };
@@ -0,0 +1,63 @@
1
+ const { askPassword, confirm, prompt, saveOrReport, dbPath, colors, log, makeRecovery, recover, checkPhrase, normalizePhrase, createMaster } = require("../utils");
2
+
3
+ // create (or replace) the 12-word recovery phrase: locks the vault key with it into db.json, shows it once
4
+ async function createRecovery(db, askKey) {
5
+ const key = await askKey();
6
+ if (!key) return;
7
+ if (db.recovery && !(await confirm("A recovery phrase already exists, the old one will stop working. " + colors.amber("Replace") + " it"))) return;
8
+
9
+ const { phrase, recovery } = makeRecovery(key);
10
+ const old = db.recovery;
11
+ db.recovery = recovery;
12
+ if (!saveOrReport(dbPath, db, "Saved recovery phrase lock to db.json", "Error while saving recovery phrase!", "added")) {
13
+ db.recovery = old;
14
+ return void (process.exitCode = 1);
15
+ }
16
+
17
+ console.log();
18
+ log.warn("Write these 12 words down (in order) and keep them offline. They are shown only once.");
19
+ log.warn("Anyone with them + db.json can read every account.");
20
+ console.log();
21
+ console.log(formatPhrase(phrase));
22
+ console.log();
23
+ log.hint("(db.json.bak still holds the previous recovery lock until the next save)");
24
+
25
+ // make sure what's on paper is right, a typo there would only be found when it's needed
26
+ const { typed } = await prompt([{ type: "input", name: "typed", message: "Type the 12 words back to confirm you wrote them down:" }]);
27
+ if (normalizePhrase(typed) === phrase) return log.ok("Recovery phrase confirmed. Clear your terminal (e.g. 'clear') so it isn't left on screen.");
28
+ process.exitCode = 1;
29
+ log.error("Doesn't match what was shown. The new phrase is saved anyway, check your copy or run 'recovery' again to get a new one.");
30
+ }
31
+
32
+ // "word1 ... word12" -> numbered words in 3 columns (amber numbers, bold words)
33
+ function formatPhrase(phrase) {
34
+ const words = phrase.split(" ");
35
+ const cell = (i) => colors.amber(String(i + 1).padStart(2) + ".") + " " + colors.bold.text(words[i].padEnd(12));
36
+ const rows = [];
37
+ for (let r = 0; r < 4; r++) rows.push(" " + [r, r + 4, r + 8].map(cell).join(" ").trimEnd());
38
+ return rows.join("\n");
39
+ }
40
+
41
+ // recovery phrase -> vault key -> new master password (rewrites credentials.json). For a lost credentials.json or a forgotten master password.
42
+ async function recoverVault(db, cred) {
43
+ if (!db.recovery) {
44
+ process.exitCode = 1;
45
+ return log.error("db.json has no recovery phrase (restore credentials.json.bak, or db.json.bak if it had one)");
46
+ }
47
+ const phrase = await askPassword("RECOVERY PHRASE (12 words):");
48
+ const problem = checkPhrase(phrase);
49
+ if (problem) {
50
+ process.exitCode = 1;
51
+ return log.error("Invalid recovery phrase: " + problem);
52
+ }
53
+ const key = recover(phrase, db.recovery);
54
+ if (!key) {
55
+ process.exitCode = 1;
56
+ return log.error("Wrong recovery phrase!");
57
+ }
58
+ log.ok("Vault unlocked with the recovery phrase");
59
+ // same vault key, so db.json and the recovery phrase keep working as they are
60
+ if (!(await createMaster(cred, key, "New MASTER PASSWORD:"))) process.exitCode = 1;
61
+ }
62
+
63
+ module.exports = { createRecovery, recoverVault, formatPhrase };
@@ -0,0 +1,31 @@
1
+ const { prompt, confirm, encrypt, saveOrReport, entryLabel, dbPath, FIELDS, SECRET_FIELDS, colors, log } = require("../utils");
2
+
3
+ async function updateEntry(entry, db, key) {
4
+ if (!(await confirm("Entry " + entryLabel(entry) + " will be " + colors.amber("updated") + ", Are you sure"))) return;
5
+
6
+ log.hint("Enter = keep current value, '-' = clear it");
7
+ const questions = FIELDS.map((f) => ({ type: "input", name: f, message: "New " + f + " [" + (SECRET_FIELDS.includes(f) && entry[f] ? "***" : entry[f] || "") + "]: " }));
8
+ questions.push({ type: "password", name: "password", message: "New password (Enter = keep): " });
9
+
10
+ const answers = await prompt(questions);
11
+ let changed = false;
12
+ FIELDS.forEach((f) => {
13
+ const value = answers[f].trim();
14
+ let next = entry[f];
15
+ if (value === "-" && f !== "name") next = "";
16
+ else if (value !== "" && value !== "-") next = SECRET_FIELDS.includes(f) ? encrypt(value, key) : value; // secrets: new value always counts as a change
17
+ if (next !== entry[f]) changed = true;
18
+ entry[f] = next;
19
+ });
20
+ if (answers.password !== "") {
21
+ entry.password = encrypt(answers.password, key);
22
+ changed = true;
23
+ }
24
+ if (!changed) return log.hint("Nothing changed");
25
+ entry.updated = new Date().toISOString();
26
+
27
+ // entry is a reference into db.accounts
28
+ saveOrReport(dbPath, db, "Updated entry #" + entry.id, "Error while updating entry!", "updated");
29
+ }
30
+
31
+ module.exports = updateEntry;
package/package.json CHANGED
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "name": "keebox",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.9.1",
4
+ "description": "KeeBox is an offline password manager that lives in your terminal",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/eissapk/keebox.git"
12
+ },
13
+ "keywords": [],
14
+ "author": "Eissa Saber",
15
+ "license": "MIT",
16
+ "bugs": {
17
+ "url": "https://github.com/eissapk/keebox/issues"
18
+ },
19
+ "homepage": "https://github.com/eissapk/keebox#readme"
20
+ }
@@ -0,0 +1,66 @@
1
+ // run: npm test (or: node --test)
2
+ const { test, describe } = require("node:test");
3
+ const assert = require("node:assert/strict");
4
+ const crypto = require("crypto");
5
+ const { encrypt, lockVault } = require("../utils");
6
+ const { checkEntry, checkEntries, checkCredentials } = require("../options/check");
7
+
8
+ const key = crypto.randomBytes(32);
9
+ const otherKey = crypto.randomBytes(32);
10
+ const good = (id, extra = {}) => ({ id, name: "github", url: "github.com", username: "foo", login: encrypt("me", key), notes: "", password: encrypt("pw", key), created: "2026-01-01T00:00:00.000Z", updated: "2026-01-01T00:00:00.000Z", ...extra });
11
+
12
+ describe("checkEntry", () => {
13
+ test("healthy entry, with and without the key", () => {
14
+ assert.deepEqual(checkEntry(good(1), key), []);
15
+ assert.deepEqual(checkEntry(good(1), null), []);
16
+ assert.deepEqual(checkEntry({ id: 2, name: "old", password: encrypt("pw", key) }, key), []); // before timestamps/secret fields existed
17
+ });
18
+
19
+ test("finds values that can't be decrypted (wrong key or flipped byte)", () => {
20
+ const pw = encrypt("pw", key);
21
+ const flipped = pw.slice(0, -1) + (pw.slice(-1) === "0" ? "1" : "0");
22
+ assert.deepEqual(checkEntry(good(1, { password: flipped }), key), ["password can't be decrypted (damaged)"]);
23
+ assert.deepEqual(checkEntry(good(1, { notes: encrypt("n", otherKey) }), key), ["notes can't be decrypted (damaged)"]);
24
+ assert.deepEqual(checkEntry(good(1, { password: flipped }), null), []); // structure only
25
+ });
26
+
27
+ test("finds broken structure", () => {
28
+ assert.deepEqual(checkEntry(null, key), ["not an account object"]);
29
+ assert.deepEqual(checkEntry(good("1", { name: " ", password: "", login: "plain", created: "nope" }), key), [
30
+ 'bad id ("1")',
31
+ "name is empty",
32
+ "password is empty",
33
+ "login isn't encrypted (unknown format)",
34
+ "created isn't a valid date",
35
+ ]);
36
+ });
37
+ });
38
+
39
+ describe("checkEntries", () => {
40
+ test("returns only damaged entries, flags duplicate ids", () => {
41
+ const damaged = checkEntries([good(1), good(2), good(2), good(3, { password: "x" })], key);
42
+ assert.deepEqual(
43
+ damaged.map((d) => [d.entry.id, d.problems]),
44
+ [
45
+ [2, ["duplicate id (2 entries use #2)"]],
46
+ [2, ["duplicate id (2 entries use #2)"]],
47
+ [3, ["password isn't encrypted (unknown format)"]],
48
+ ]
49
+ );
50
+ });
51
+ });
52
+
53
+ describe("checkCredentials", () => {
54
+ test("healthy credentials", () => {
55
+ assert.deepEqual(checkCredentials(lockVault("master", key), [good(1)]), []);
56
+ });
57
+
58
+ test("missing master password is only a problem when db has encrypted entries", () => {
59
+ assert.deepEqual(checkCredentials({}, []), []);
60
+ assert.match(checkCredentials({}, [good(1)])[0], /no master password/);
61
+ });
62
+
63
+ test("bad salt / n / key", () => {
64
+ assert.deepEqual(checkCredentials({ salt: "zz", n: 1000, key: "nope" }, []), ["credentials.json: bad salt", "credentials.json: bad n (must be a power of 2)", "credentials.json: key isn't encrypted (unknown format)"]);
65
+ });
66
+ });
@@ -0,0 +1,200 @@
1
+ // run: npm test (or: node --test)
2
+ const { test, describe } = require("node:test");
3
+ const assert = require("node:assert/strict");
4
+ const crypto = require("crypto");
5
+ // load through utils/index.js: it wires vault.js in at the end (loading vault.js first breaks config.js)
6
+ const { encrypt, decrypt, decryptField, mkPass, lockVault, hasMaster, unlock } = require("../utils");
7
+
8
+ const FORMAT = /^[0-9a-f]{24}\.[0-9a-f]{32}\.[0-9a-f]*$/; // "iv.tag.data"
9
+ const newKey = () => crypto.randomBytes(32);
10
+
11
+ // flip one hex digit of part `i` ("iv" = 0, "tag" = 1, "data" = 2)
12
+ function tamper(str, i) {
13
+ const parts = str.split(".");
14
+ const p = parts[i];
15
+ parts[i] = (p[0] === "0" ? "1" : "0") + p.slice(1);
16
+ return parts.join(".");
17
+ }
18
+
19
+ describe("encrypt / decrypt", () => {
20
+ test("round-trips text", () => {
21
+ const key = newKey();
22
+ for (const text of ["hunter2", "", "ünïcødé 🔑 كلمة", "a".repeat(10000), "with.dots.inside"]) {
23
+ assert.equal(decrypt(encrypt(text, key), key), text);
24
+ }
25
+ });
26
+
27
+ test('output is "iv.tag.data" hex', () => {
28
+ const out = encrypt("secret", newKey());
29
+ assert.match(out, FORMAT);
30
+ assert.equal(out.split(".")[2].length, "secret".length * 2); // GCM: ciphertext length = plaintext length
31
+ });
32
+
33
+ test("ciphertext does not contain the plaintext", () => {
34
+ const text = "plain-text-password";
35
+ const out = encrypt(text, newKey());
36
+ assert.ok(!out.includes(text));
37
+ assert.ok(!out.includes(Buffer.from(text).toString("hex")));
38
+ });
39
+
40
+ test("uses a fresh IV every time", () => {
41
+ const key = newKey();
42
+ const a = encrypt("same", key);
43
+ const b = encrypt("same", key);
44
+ assert.notEqual(a, b);
45
+ assert.notEqual(a.split(".")[0], b.split(".")[0]);
46
+ });
47
+
48
+ test("wrong key throws", () => {
49
+ const out = encrypt("secret", newKey());
50
+ assert.throws(() => decrypt(out, newKey()));
51
+ });
52
+
53
+ test("tampered iv, tag or data throws", () => {
54
+ const key = newKey();
55
+ const out = encrypt("secret", key);
56
+ for (const i of [0, 1, 2]) assert.throws(() => decrypt(tamper(out, i), key), `part ${i}`);
57
+ });
58
+
59
+ test("truncated ciphertext throws", () => {
60
+ const key = newKey();
61
+ const out = encrypt("secret", key);
62
+ assert.throws(() => decrypt(out.slice(0, -2), key));
63
+ });
64
+
65
+ test("rejects strings that aren't in the encrypt() format", () => {
66
+ const key = newKey();
67
+ for (const bad of ["", "plaintext", "a.b.c", "00.11.22", encrypt("x", key).toUpperCase(), undefined, null]) {
68
+ assert.throws(() => decrypt(bad, key), /unknown encryption format/, String(bad));
69
+ }
70
+ });
71
+
72
+ test("rejects keys that aren't 256-bit", () => {
73
+ assert.throws(() => encrypt("x", crypto.randomBytes(16)));
74
+ });
75
+ });
76
+
77
+ describe("decryptField", () => {
78
+ // silences console.error and restores process.exitCode
79
+ function quietly(fn) {
80
+ const { error } = console;
81
+ const code = process.exitCode;
82
+ const logged = [];
83
+ console.error = (...args) => logged.push(args.join(" "));
84
+ try {
85
+ return { result: fn(), exitCode: process.exitCode, logged };
86
+ } finally {
87
+ console.error = error;
88
+ process.exitCode = code;
89
+ }
90
+ }
91
+
92
+ test("decrypts a field", () => {
93
+ const key = newKey();
94
+ assert.equal(decryptField({ id: 1, notes: encrypt("my notes", key) }, "notes", key), "my notes");
95
+ });
96
+
97
+ test('empty or missing field -> ""', () => {
98
+ const key = newKey();
99
+ assert.equal(decryptField({ id: 1, notes: "" }, "notes", key), "");
100
+ assert.equal(decryptField({ id: 1 }, "notes", key), "");
101
+ });
102
+
103
+ test("damaged field -> null, error printed, exit code 1", () => {
104
+ const key = newKey();
105
+ const entry = { id: 7, notes: tamper(encrypt("x", key), 2) };
106
+ const { result, exitCode, logged } = quietly(() => decryptField(entry, "notes", key));
107
+ assert.equal(result, null);
108
+ assert.equal(exitCode, 1);
109
+ assert.match(logged.join("\n"), /notes of entry #7/);
110
+ });
111
+ });
112
+
113
+ describe("mkPass", () => {
114
+ const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^a-zA-Z0-9]/];
115
+
116
+ test("has the exact length", () => {
117
+ for (const n of [4, 5, 8, 16, 32, 64, 128]) assert.equal(mkPass(n).length, n);
118
+ });
119
+
120
+ test("always has one char of each class", () => {
121
+ for (let i = 0; i < 500; i++) {
122
+ const p = mkPass(4); // shortest: exactly one of each
123
+ for (const re of classes) assert.match(p, re);
124
+ }
125
+ });
126
+
127
+ test("uses only printable ASCII, no spaces", () => {
128
+ for (let i = 0; i < 100; i++) assert.match(mkPass(32), /^[\x21-\x7e]+$/);
129
+ });
130
+
131
+ test("doesn't repeat", () => {
132
+ const seen = new Set();
133
+ for (let i = 0; i < 1000; i++) seen.add(mkPass(16));
134
+ assert.equal(seen.size, 1000);
135
+ });
136
+
137
+ test("required chars aren't stuck in fixed positions (shuffled)", () => {
138
+ // without the shuffle, position 0 would always be lowercase
139
+ const firsts = new Set();
140
+ for (let i = 0; i < 200; i++) {
141
+ const c = mkPass(4)[0];
142
+ firsts.add(classes.findIndex((re) => re.test(c)));
143
+ }
144
+ assert.equal(firsts.size, 4);
145
+ });
146
+ });
147
+
148
+ describe("vault (master password)", () => {
149
+ // scrypt is slow on purpose (~100ms per call), so share one locked vault
150
+ const password = "correct horse battery staple";
151
+ const vaultKey = newKey();
152
+ const cred = lockVault(password, vaultKey);
153
+
154
+ test("lockVault -> { salt, n, key }", () => {
155
+ assert.deepEqual(Object.keys(cred).sort(), ["key", "n", "salt"]);
156
+ assert.match(cred.salt, /^[0-9a-f]{32}$/);
157
+ assert.equal(cred.n, 32768);
158
+ assert.match(cred.key, FORMAT);
159
+ assert.ok(!cred.key.includes(vaultKey.toString("hex")));
160
+ });
161
+
162
+ test("right password unlocks the vault key", () => {
163
+ const key = unlock(password, cred);
164
+ assert.ok(Buffer.isBuffer(key));
165
+ assert.ok(key.equals(vaultKey));
166
+ });
167
+
168
+ test("wrong password -> null", () => {
169
+ assert.equal(unlock("wrong", cred), null);
170
+ assert.equal(unlock("", cred), null);
171
+ assert.equal(unlock(password + " ", cred), null); // callers trim, unlock itself doesn't
172
+ });
173
+
174
+ test("tampered credentials -> null", () => {
175
+ assert.equal(unlock(password, { ...cred, key: tamper(cred.key, 2) }), null);
176
+ assert.equal(unlock(password, { ...cred, salt: tamper(cred.salt, 0) }), null);
177
+ assert.equal(unlock(password, { ...cred, n: 16384 }), null);
178
+ });
179
+
180
+ test("re-locking uses a new salt, old and new both open the same key", () => {
181
+ const again = lockVault(password, vaultKey);
182
+ assert.notEqual(again.salt, cred.salt);
183
+ assert.notEqual(again.key, cred.key);
184
+ assert.ok(unlock(password, again).equals(vaultKey));
185
+ });
186
+
187
+ test("changing the master password keeps the vault key, so existing entries still decrypt", () => {
188
+ const entry = encrypt("site password", vaultKey);
189
+ const changed = lockVault("new master", unlock(password, cred));
190
+ assert.equal(unlock(password, changed), null);
191
+ assert.equal(decrypt(entry, unlock("new master", changed)), "site password");
192
+ });
193
+
194
+ test("hasMaster / unlock before a master password exists", () => {
195
+ assert.equal(hasMaster(cred), true);
196
+ assert.equal(hasMaster({}), false);
197
+ assert.equal(hasMaster({ key: "" }), false);
198
+ assert.equal(unlock(password, {}), null);
199
+ });
200
+ });
@@ -0,0 +1,56 @@
1
+ // run: npm test (or: node --test)
2
+ const { test, describe } = require("node:test");
3
+ const assert = require("node:assert/strict");
4
+ const crypto = require("crypto");
5
+ const { encrypt } = require("../utils");
6
+ const { toCsv } = require("../utils/csv");
7
+ const { entriesToCsv } = require("../options/export");
8
+
9
+ describe("toCsv", () => {
10
+ test("plain fields stay unquoted, rows end with CRLF", () => {
11
+ assert.equal(toCsv([["a", "b"], [1, ""]]), "a,b\r\n1,\r\n");
12
+ });
13
+
14
+ test("quotes fields with commas, quotes and line breaks", () => {
15
+ assert.equal(toCsv([['a,b', 'say "hi"', "l1\nl2", "x\ry"]]), '"a,b","say ""hi""","l1\nl2","x\ry"\r\n');
16
+ });
17
+
18
+ test("keeps leading = + - @ as-is", () => {
19
+ assert.equal(toCsv([["=1+1", "-x", "@y"]]), "=1+1,-x,@y\r\n");
20
+ });
21
+
22
+ test("null / undefined become empty fields", () => {
23
+ assert.equal(toCsv([[null, undefined, 0]]), ",,0\r\n");
24
+ });
25
+ });
26
+
27
+ describe("entriesToCsv", () => {
28
+ const key = crypto.randomBytes(32);
29
+ const entry = {
30
+ id: 1, name: "github", url: "github.com", username: "foo",
31
+ login: encrypt("foo@mail.com", key), notes: encrypt("2FA, phone", key), password: encrypt('p"w,1', key),
32
+ created: "2026-01-01T00:00:00.000Z", updated: "2026-01-02T00:00:00.000Z",
33
+ };
34
+
35
+ test("decrypts secrets into the right columns", () => {
36
+ const csv = entriesToCsv([entry, { id: 2, name: "x", url: "", username: "", login: "", notes: "", password: encrypt("p", key) }], key);
37
+ assert.equal(
38
+ csv,
39
+ "id,name,url,username,login,notes,password,created,updated\r\n" +
40
+ '1,github,github.com,foo,foo@mail.com,"2FA, phone","p""w,1",2026-01-01T00:00:00.000Z,2026-01-02T00:00:00.000Z\r\n' +
41
+ "2,x,,,,,p,,\r\n"
42
+ );
43
+ });
44
+
45
+ test("returns null with a wrong key (nothing partial)", () => {
46
+ const exitCode = process.exitCode;
47
+ const error = console.error;
48
+ console.error = () => {};
49
+ try {
50
+ assert.equal(entriesToCsv([entry], crypto.randomBytes(32)), null);
51
+ } finally {
52
+ console.error = error;
53
+ process.exitCode = exitCode;
54
+ }
55
+ });
56
+ });
@@ -0,0 +1,43 @@
1
+ // run: npm test (or: node --test)
2
+ const { test, describe } = require("node:test");
3
+ const assert = require("node:assert/strict");
4
+ const { toCsv, parseCsv } = require("../utils/csv");
5
+ const { rowsToRecords } = require("../options/import");
6
+
7
+ describe("parseCsv", () => {
8
+ test("round-trips toCsv output", () => {
9
+ const rows = [["id", "notes", "password"], ["1", 'a, "b"\nline 2', "=x"], ["2", "", "-y"], ["3", "\r\n", '"']];
10
+ assert.deepEqual(parseCsv(toCsv(rows)), rows);
11
+ });
12
+
13
+ test("accepts LF, BOM, no trailing newline, skips blank lines", () => {
14
+ assert.deepEqual(parseCsv("a,b\n\n1,2\n\n3,"), [["a", "b"], ["1", "2"], ["3", ""]]);
15
+ });
16
+
17
+ test("empty quoted fields", () => {
18
+ assert.deepEqual(parseCsv('"",x,""\r\n'), [["", "x", ""]]);
19
+ });
20
+
21
+ test("throws on an unclosed quote or text after a closing quote", () => {
22
+ assert.throws(() => parseCsv('a\n"b,c\n'), /unclosed quote \(line 2\)/);
23
+ assert.throws(() => parseCsv('"a"b,c\n'), /after closing quote/);
24
+ });
25
+ });
26
+
27
+ describe("rowsToRecords", () => {
28
+ test("maps columns by header name, any order / case, ignores unknown ones", () => {
29
+ const { records, errors } = rowsToRecords([["Password", "ID", " Name ", "extra"], ["pw", "7", " github ", "?"]]);
30
+ assert.deepEqual(errors, []);
31
+ assert.deepEqual(records, [{ password: "pw", id: "7", name: "github", extra: "?" }]);
32
+ });
33
+
34
+ test("requires name and password columns", () => {
35
+ assert.match(rowsToRecords([["name", "url"], ["a", "b"]]).errors[0], /missing column\(s\): password/);
36
+ assert.match(rowsToRecords([]).errors[0], /empty/);
37
+ });
38
+
39
+ test("reports rows with a wrong column count or an empty name", () => {
40
+ const { errors } = rowsToRecords([["name", "password"], ["a"], [" ", "pw"], ["ok", "pw"]]);
41
+ assert.deepEqual(errors, ["row 1: has 1 columns, header has 2", "row 2: name is empty"]);
42
+ });
43
+ });