feat: add native KDBX scaffolding and in-memory KeePass API
This commit is contained in:
+58
-246
@@ -1,267 +1,79 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { KeePassDatabase, openKeePassDatabase } from "../../src/keepass";
|
||||
|
||||
const spawnMock = mock(() => {
|
||||
throw new Error("spawn should be mocked per test");
|
||||
});
|
||||
|
||||
mock.module("node:child_process", () => ({
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
function mockSuccessfulBridgeResponse(data: unknown) {
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = {
|
||||
stdin: { write: () => undefined, end: () => undefined },
|
||||
stdout: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb(JSON.stringify({ ok: true, data })) },
|
||||
stderr: { on: () => undefined },
|
||||
on: (event: string, cb: (code?: number | null) => void) => {
|
||||
if (event === "close") queueMicrotask(() => cb(0));
|
||||
},
|
||||
};
|
||||
return child as never;
|
||||
});
|
||||
}
|
||||
|
||||
describe("KeePassDatabase", () => {
|
||||
test("listEntries parses successful bridge response", async () => {
|
||||
mockSuccessfulBridgeResponse([{ title: "Entry" }]);
|
||||
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
|
||||
const entries = await db.listEntries();
|
||||
|
||||
expect(entries).toEqual([{ title: "Entry" }]);
|
||||
expect(spawnMock).toHaveBeenCalled();
|
||||
test("starts with an empty root group", async () => {
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" });
|
||||
expect(await db.listGroups()).toEqual([{ name: "Racine", path: "" }]);
|
||||
expect(await db.listEntries()).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws on bridge error payload", async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = {
|
||||
stdin: { write: () => undefined, end: () => undefined },
|
||||
stdout: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb('{"ok":false,"error":"boom"}') },
|
||||
stderr: { on: () => undefined },
|
||||
on: (event: string, cb: (code?: number | null) => void) => {
|
||||
if (event === "close") queueMicrotask(() => cb(1));
|
||||
},
|
||||
};
|
||||
return child as never;
|
||||
test("creates entries in memory", async () => {
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" });
|
||||
const created = await db.createEntry({
|
||||
title: "Entry",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
groupPath: "Folder",
|
||||
});
|
||||
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
|
||||
await expect(db.listEntries()).rejects.toThrow("boom");
|
||||
expect(created).toEqual({
|
||||
title: "Entry",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "",
|
||||
notes: "",
|
||||
groupPath: "Folder",
|
||||
});
|
||||
expect(await db.listEntries()).toEqual([created]);
|
||||
});
|
||||
|
||||
test("throws on empty bridge output", async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = {
|
||||
stdin: { write: () => undefined, end: () => undefined },
|
||||
stdout: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb(" ") },
|
||||
stderr: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb("bridge failed") },
|
||||
on: (event: string, cb: (code?: number | null) => void) => {
|
||||
if (event === "close") queueMicrotask(() => cb(1));
|
||||
},
|
||||
};
|
||||
return child as never;
|
||||
test("creates groups in memory", async () => {
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" });
|
||||
const created = await db.createGroup({ name: "Folder1" });
|
||||
|
||||
expect(created).toEqual({ name: "Folder1", path: "Folder1" });
|
||||
expect(await db.listGroups()).toEqual([
|
||||
{ name: "Racine", path: "" },
|
||||
{ name: "Folder1", path: "Folder1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("findEntries performs partial matching", async () => {
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" });
|
||||
await db.createEntry({
|
||||
title: "Mail",
|
||||
username: "alice",
|
||||
password: "pass",
|
||||
url: "https://example.com",
|
||||
groupPath: "Folder1/SubFolder",
|
||||
});
|
||||
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
|
||||
await expect(db.listEntries()).rejects.toThrow("bridge failed");
|
||||
expect(await db.findEntries({ title: "mai" })).toHaveLength(1);
|
||||
expect(await db.findEntries({ username: "ALI" })).toHaveLength(1);
|
||||
expect(await db.findEntries({ url: "example" })).toHaveLength(1);
|
||||
expect(await db.findEntries({ groupPath: "Folder1" })).toHaveLength(1);
|
||||
expect(await db.findEntries({ title: "missing" })).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws on invalid JSON output", async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = {
|
||||
stdin: { write: () => undefined, end: () => undefined },
|
||||
stdout: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb("not json") },
|
||||
stderr: { on: () => undefined },
|
||||
on: (event: string, cb: (code?: number | null) => void) => {
|
||||
if (event === "close") queueMicrotask(() => cb(0));
|
||||
},
|
||||
};
|
||||
return child as never;
|
||||
});
|
||||
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
|
||||
await expect(db.listEntries()).rejects.toThrow("Invalid JSON from Python bridge");
|
||||
});
|
||||
|
||||
test("throws a useful error when the bridge exits without output", async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = {
|
||||
stdin: { write: () => undefined, end: () => undefined },
|
||||
stdout: { on: () => undefined },
|
||||
stderr: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb("bridge crashed") },
|
||||
on: (event: string, cb: (code?: number | null) => void) => {
|
||||
if (event === "close") queueMicrotask(() => cb(1));
|
||||
},
|
||||
};
|
||||
return child as never;
|
||||
});
|
||||
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
|
||||
await expect(db.listEntries()).rejects.toThrow("bridge crashed");
|
||||
});
|
||||
|
||||
test("throws on spawn error", async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = {
|
||||
stdin: { write: () => undefined, end: () => undefined },
|
||||
stdout: { on: () => undefined },
|
||||
stderr: { on: () => undefined },
|
||||
on: (event: string, cb: (error: Error) => void) => {
|
||||
if (event === "error") queueMicrotask(() => cb(new Error("spawn failed")));
|
||||
},
|
||||
};
|
||||
return child as never;
|
||||
});
|
||||
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
|
||||
await expect(db.listEntries()).rejects.toThrow("spawn failed");
|
||||
});
|
||||
|
||||
test("createEntry forwards the create-entry command", async () => {
|
||||
mockSuccessfulBridgeResponse({ title: "New" });
|
||||
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
|
||||
const created = await db.createEntry({ title: "New" });
|
||||
|
||||
expect(created).toEqual({ title: "New" });
|
||||
expect(spawnMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("createGroup forwards the create-group command", async () => {
|
||||
mockSuccessfulBridgeResponse({ name: "Folder", path: "" });
|
||||
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
|
||||
const created = await db.createGroup({ name: "Folder" });
|
||||
|
||||
expect(created).toEqual({ name: "Folder", path: "" });
|
||||
expect(spawnMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("createEntry forwards nested group paths in the payload", async () => {
|
||||
let payload = "";
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = {
|
||||
stdin: {
|
||||
write: (chunk: string) => {
|
||||
payload += chunk;
|
||||
},
|
||||
end: () => undefined,
|
||||
},
|
||||
stdout: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb(JSON.stringify({ ok: true, data: { title: "New" } })) },
|
||||
stderr: { on: () => undefined },
|
||||
on: (event: string, cb: (code?: number | null) => void) => {
|
||||
if (event === "close") queueMicrotask(() => cb(0));
|
||||
},
|
||||
};
|
||||
return child as never;
|
||||
});
|
||||
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
|
||||
await db.createEntry({ title: "New", groupPath: "Folder/SubFolder" });
|
||||
|
||||
expect(JSON.parse(payload)).toMatchObject({
|
||||
command: "create-entry",
|
||||
entry: { title: "New", groupPath: "Folder/SubFolder" },
|
||||
});
|
||||
});
|
||||
|
||||
test("save forwards the save command", async () => {
|
||||
mockSuccessfulBridgeResponse(null);
|
||||
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
|
||||
test("save clears the dirty flag without throwing", async () => {
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" });
|
||||
await db.createEntry({ title: "Entry" });
|
||||
await expect(db.save()).resolves.toBeUndefined();
|
||||
expect(spawnMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("findEntries forwards the query payload", async () => {
|
||||
let payload = "";
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = {
|
||||
stdin: {
|
||||
write: (chunk: string) => {
|
||||
payload += chunk;
|
||||
},
|
||||
end: () => undefined,
|
||||
},
|
||||
stdout: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb(JSON.stringify({ ok: true, data: [] })) },
|
||||
stderr: { on: () => undefined },
|
||||
on: (event: string, cb: (code?: number | null) => void) => {
|
||||
if (event === "close") queueMicrotask(() => cb(0));
|
||||
},
|
||||
};
|
||||
return child as never;
|
||||
});
|
||||
test("entry and group collections are cloned on read", async () => {
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" });
|
||||
await db.createEntry({ title: "Entry" });
|
||||
await db.createGroup({ name: "Folder1" });
|
||||
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
|
||||
await db.findEntries({ title: "abc", username: "u", url: "https://x", groupPath: "Folder" });
|
||||
const entries = await db.listEntries();
|
||||
const groups = await db.listGroups();
|
||||
entries.push({ title: "X", username: "", password: "", url: "", notes: "" });
|
||||
groups.push({ name: "X", path: "X" });
|
||||
|
||||
expect(JSON.parse(payload)).toMatchObject({
|
||||
command: "find-entries",
|
||||
path: "db.kdbx",
|
||||
password: "secret",
|
||||
query: { title: "abc", username: "u", url: "https://x", groupPath: "Folder" },
|
||||
});
|
||||
});
|
||||
|
||||
test("listGroups forwards the list-groups command", async () => {
|
||||
let payload = "";
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = {
|
||||
stdin: {
|
||||
write: (chunk: string) => {
|
||||
payload += chunk;
|
||||
},
|
||||
end: () => undefined,
|
||||
},
|
||||
stdout: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb(JSON.stringify({ ok: true, data: [] })) },
|
||||
stderr: { on: () => undefined },
|
||||
on: (event: string, cb: (code?: number | null) => void) => {
|
||||
if (event === "close") queueMicrotask(() => cb(0));
|
||||
},
|
||||
};
|
||||
return child as never;
|
||||
});
|
||||
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
|
||||
await db.listGroups();
|
||||
|
||||
expect(JSON.parse(payload)).toMatchObject({
|
||||
command: "list-groups",
|
||||
path: "db.kdbx",
|
||||
password: "secret",
|
||||
});
|
||||
});
|
||||
|
||||
test("passes keyFile in the bridge payload", async () => {
|
||||
let payload = "";
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = {
|
||||
stdin: {
|
||||
write: (chunk: string) => {
|
||||
payload += chunk;
|
||||
},
|
||||
end: () => undefined,
|
||||
},
|
||||
stdout: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb(JSON.stringify({ ok: true, data: [] })) },
|
||||
stderr: { on: () => undefined },
|
||||
on: (event: string, cb: (code?: number | null) => void) => {
|
||||
if (event === "close") queueMicrotask(() => cb(0));
|
||||
},
|
||||
};
|
||||
return child as never;
|
||||
});
|
||||
|
||||
const db = new KeePassDatabase("db.kdbx", { password: "secret", keyFile: "keyfile.key" }, "python3", new URL("file:///tmp/bridge.py"));
|
||||
await db.listEntries();
|
||||
|
||||
expect(JSON.parse(payload)).toMatchObject({
|
||||
path: "db.kdbx",
|
||||
password: "secret",
|
||||
keyFile: "keyfile.key",
|
||||
command: "list-entries",
|
||||
});
|
||||
expect(await db.listEntries()).toHaveLength(1);
|
||||
expect(await db.listGroups()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("openKeePassDatabase returns a KeePassDatabase instance", () => {
|
||||
|
||||
Reference in New Issue
Block a user