7 Commits

Author SHA1 Message Date
matmoul 210f7b414b fix: include bun types and test files in tsconfig 2026-05-10 01:02:03 +02:00
matmoul 5fa30414d7 fix: normalize bridge errors and support nested group paths
Distinguish invalid KeePass requests from backend failures in the Python bridge, improve nested group path resolution, and add coverage for nested group creation plus payload forwarding.
2026-05-10 00:56:58 +02:00
matmoul ee0e2c85f4 chore: rename project to ts-pykeepass-wrapper 2026-05-10 00:43:00 +02:00
matmoul 8e990cb1b4 chore: track project memory files in git 2026-05-10 00:32:53 +02:00
matmoul 2d444e9a8b test: expand bridge unit coverage for payloads and errors 2026-05-10 00:29:48 +02:00
matmoul da0b396bf8 feat: add write support for KeePass entries and groups 2026-05-10 00:19:42 +02:00
matmoul 89ba04d61a docs: clarify Python bridge runtime and test behavior 2026-05-10 00:06:54 +02:00
12 changed files with 525 additions and 101 deletions
+3 -1
View File
@@ -30,4 +30,6 @@ __pycache__/
.mypy_cache/
# Local project memory/state
.memory/
# Keep memory files tracked
!.memory/
!.memory/**
+52 -20
View File
@@ -1,20 +1,52 @@
{
"name": "kdbx-lib",
"packageManager": "bun@1.0.0",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"example": "bun run src/example.ts",
"validate": "bun run test",
"test": "bun test",
"test:unit": "bun test",
"test:integration": "bun run src/test-integration.ts",
"setup:python": "python3 -m venv .venv && .venv/bin/pip install pykeepass"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.5.0",
"bun-types": "^1.1.0"
}
}
# Project
## Goal
Provide a TypeScript wrapper around KeePass `.kdbx` databases using a Python bridge powered by `pykeepass`.
## Architecture
- Public API is TypeScript.
- `src/python/bridge.py` is the runtime backend and uses `pykeepass`.
- TypeScript spawns a Python process per request; there is no persistent worker yet.
- JSON is exchanged over stdin/stdout.
- Bridge errors, empty output, invalid JSON, missing files, and backend exceptions are surfaced as TypeScript errors.
- Bridge error reporting now distinguishes invalid KeePass requests from backend errors.
- Coverage now includes `keyFile` payload propagation, nested group payload shaping, and core API smoke checks.
## Public API
- `openKeePassDatabase(path, options)`
- `KeePassDatabase.listEntries()`
- `KeePassDatabase.findEntries(query)`
- `KeePassDatabase.listGroups()`
- `KeePassDatabase.createEntry(entry)`
- `KeePassDatabase.createGroup(group)`
- `KeePassDatabase.save()`
- `KeePassDatabase.close()` is a no-op.
## Types
- Entries expose: `title`, `username`, `password`, `url`, `notes`, optional `groupPath`, optional `otp`.
- Groups expose: `name`, `path`.
- Open options support `password` and optional `keyFile`.
- Find queries support partial matching on `title`, `username`, `url`, and `groupPath`.
## Runtime details
- Python path defaults to `.venv/bin/python3`.
- It can be overridden with `PYTHON_PATH`.
- `bun run setup:python` creates `.venv` if needed and installs `pykeepass`.
- The bridge also works with an existing project-local virtual environment.
## Fixtures and tests
- Bundled fixtures: `tests/fixtures/data.kdbx` and `tests/fixtures/empty.kdbx`.
- Companion JSON fixture: `tests/fixtures/data.kdbx.json` stores the password and expected content.
- Unit tests in `tests/unit/` mock the child process and validate bridge parsing, error handling, command forwarding, and payload shaping.
- Integration tests in `tests/integration/` use `data.kdbx` to verify entries, groups, partial search, OTP/TOTP output, write persistence on a temporary copy, and nested group creation when `pykeepass` is installed.
- The integration test runner checks for `pykeepass` and skips cleanly when it is unavailable.
- Memory tracking files: `.memory/state.md` and `.memory/todo.md`.
## Main scripts
- `bun run test` / `bun test`
- `bun run src/example.ts`
- `bun run src/test-integration.ts`
- `bun run setup:python`
## Current direction
Keep improving failure-path coverage, keep write support minimal and predictable, and continue validating persistence on temporary copies and nested group behavior.
+6 -22
View File
@@ -1,24 +1,8 @@
# State
## Current focus
Read-only TypeScript wrapper around `pykeepass` via a Python JSON bridge.
## Current API
- `openKeePassDatabase(path, options)`
- `listEntries()`
- `findEntries(query)`
- `listGroups()`
- `close()` is a no-op
## Runtime model
- TypeScript starts the Python bridge
- Python uses `pykeepass`
- JSON is exchanged over stdin/stdout
- Bridge errors and empty/invalid JSON are surfaced to TypeScript
## Current fixture/test status
- Bundled fixtures: `tests/fixtures/data.kdbx` and `tests/fixtures/empty.kdbx`
- Integration tests validate entries, groups, and OTP/TOTP output for `data.kdbx`
## Next step
Keep tightening failure-path coverage and improve the API shape only if needed.
- Hardened bridge error handling and nested group path resolution.
- Added unit coverage for invalid JSON, empty output, nested group path forwarding, and keyFile payloads.
- Added integration coverage for creating groups on temporary copies.
- Latest test run passed: 20 tests, 0 failures.
- VS Code red squiggles on Bun/Node imports were addressed by including `bun-types` in `tsconfig.json` and covering `tests/**/*.ts`.
- Project renamed to ts-pykeepass-wrapper; current focus remains the TypeScript wrapper + Python bridge for KeePass.
+5
View File
@@ -0,0 +1,5 @@
# Todo
- Keep write-path behavior predictable and well-documented.
- Preserve minimal API surface until update/delete/move is required.
- Consider typed bridge errors in TypeScript if more granularity is needed later.
+36 -10
View File
@@ -1,22 +1,22 @@
# kdbx-lib
# ts-pykeepass-wrapper
TypeScript wrapper around `pykeepass` for reading KeePass `.kdbx` files.
TypeScript wrapper around `pykeepass` for reading and modifying KeePass `.kdbx` files.
## Overview
This project uses:
- TypeScript as the public API
- Python as the runtime backend
- `pykeepass` to read KeePass databases
- `pykeepass` to read and update KeePass databases
The TypeScript layer launches a Python bridge and exchanges JSON through stdin/stdout.
The TypeScript layer launches a Python bridge per request and exchanges JSON through stdin/stdout.
## Requirements
- Node.js or Bun
- Python 3
- `pykeepass` installed in the Python environment used by the bridge (the project provides `bun run setup:python`)
- The bridge defaults to `.venv/bin/python3` when available, or you can override with `PYTHON_PATH`
- The bridge defaults to `.venv/bin/python3`, or you can override with `PYTHON_PATH`
## Python setup
@@ -32,7 +32,7 @@ If you prefer manual installation:
python3 -m venv .venv && .venv/bin/pip install pykeepass
```
The bridge also works with a project-local virtual environment such as `.venv` and the tests will use it when present.
The bridge also works with a project-local virtual environment such as `.venv`.
## Usage
@@ -62,7 +62,7 @@ bun run src/example.ts
Creates a database wrapper.
#### Options
- `password`: KeePass master password, read from the fixture JSON in examples/tests when applicable
- `password`: KeePass master password
- `keyFile`: optional key file path
### `listEntries()`
@@ -74,6 +74,31 @@ Finds entries by partial match and returns the full entry objects.
### `listGroups()`
Returns all groups, including their names and paths.
### `createEntry(entry)`
Creates a new entry in the target database and persists it immediately.
#### Entry input
- `title`: entry title
- `username`: optional username
- `password`: optional password
- `url`: optional URL
- `notes`: optional notes
- `groupPath`: optional target group path
`groupPath` is resolved as an existing group path when possible. Nested paths such as `Folder1/SubFolder` are supported when the target group exists.
### `createGroup(group)`
Creates a new group and persists it immediately.
#### Group input
- `name`: group name
- `path`: optional parent group path
`path` is resolved as an existing parent group path when possible, including nested paths.
### `save()`
Persists the current database state.
### `close()`
No-op for now.
@@ -81,7 +106,8 @@ No-op for now.
- The bridge currently launches a Python process per call.
- This is simple and robust for a first version.
- Errors from the Python bridge are propagated to the TypeScript API with the bridge exit code when available.
- Errors from the Python bridge are propagated to the TypeScript API, including invalid or empty output. Bridge failures are normalized to distinguish invalid requests and backend errors.
- A persistent Python process can be added later if needed.
- Bundled fixtures include `tests/fixtures/data.kdbx` and `tests/fixtures/empty.kdbx`; their companion JSON files store the password and expected content for tests/examples.
- Integration tests validate the bundled `data.kdbx` entry-by-entry and group-by-group against `tests/fixtures/data.kdbx.json`.
- Write operations currently open, modify, and save the database per command.
- Bundled fixtures include `tests/fixtures/data.kdbx` and `tests/fixtures/empty.kdbx`; the companion JSON file stores the password and expected content for tests/examples.
- Integration tests validate the bundled `data.kdbx` entry-by-entry and group-by-group against `tests/fixtures/data.kdbx.json`, and may skip cleanly when `pykeepass` is unavailable.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "kdbx-lib",
"name": "ts-pykeepass-wrapper",
"packageManager": "bun@1.0.0",
"version": "0.1.0",
"private": true,
+28 -16
View File
@@ -3,18 +3,20 @@ import { fileURLToPath } from "node:url";
import type {
KeePassCommand,
KeePassEntry,
KeePassEntryInput,
KeePassFindQuery,
KeePassGroup,
KeePassGroupInput,
KeePassOpenOptions,
KeePassResponse,
} from "./types";
export class KeePassDatabase {
constructor(
private readonly path: string,
private readonly options: KeePassOpenOptions,
private readonly pythonPath = process.env.PYTHON_PATH ?? ".venv/bin/python3",
private readonly bridgePath = new URL("./python/bridge.py", import.meta.url)
private path: string,
private options: KeePassOpenOptions,
private pythonPath = process.env.PYTHON_PATH ?? ".venv/bin/python3",
private bridgePath = new URL("./python/bridge.py", import.meta.url)
) {}
async listEntries(): Promise<KeePassEntry[]> {
@@ -32,8 +34,19 @@ export class KeePassDatabase {
return response;
}
async createEntry(entry: KeePassEntryInput): Promise<KeePassEntry> {
return this.run<KeePassEntry>({ command: "create-entry", entry });
}
async createGroup(group: KeePassGroupInput): Promise<KeePassGroup> {
return this.run<KeePassGroup>({ command: "create-group", group });
}
async save(): Promise<void> {
await this.run<void>({ command: "save" });
}
async close(): Promise<void> {
// No persistent process is kept alive yet.
return;
}
@@ -47,19 +60,23 @@ export class KeePassDatabase {
const bridgeFile = fileURLToPath(this.bridgePath);
const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {
const child = spawn(this.pythonPath, [bridgeFile], {
stdio: ["pipe", "pipe", "pipe"],
});
const child = spawn(this.pythonPath, [bridgeFile], { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
let settled = false;
const fail = (error: unknown) => {
if (!settled) {
settled = true;
reject(error instanceof Error ? error : new Error(String(error)));
}
};
try {
child.stdin.write(payload);
child.stdin.end();
} catch (error) {
reject(error);
fail(error);
}
child.stdout.on("data", (chunk) => {
@@ -70,12 +87,7 @@ export class KeePassDatabase {
stderr += chunk.toString();
});
child.on("error", (error) => {
if (!settled) {
settled = true;
reject(error);
}
});
child.on("error", fail);
child.on("close", (code) => {
if (!settled) {
+75 -1
View File
@@ -60,6 +60,41 @@ def load_db(db_path, password, key_file=None):
return PyKeePass(db_path, password=password)
def save_db(db):
db.save()
def normalize_path(value):
if value is None:
return ""
return str(value).strip()
def resolve_group_by_path(db, group_path):
normalized = normalize_path(group_path)
if not normalized:
return db.root_group
candidate_paths = [normalized]
if normalized.startswith("/"):
candidate_paths.append(normalized.lstrip("/"))
else:
candidate_paths.append(f"/{normalized}")
for candidate in candidate_paths:
matching_groups = db.find_groups(path=candidate)
if matching_groups:
return matching_groups[0]
segments = [segment for segment in normalized.strip("/").split("/") if segment]
matching_groups = db.find_groups(name=segments[-1])
for group in matching_groups:
if path_to_string(group.path).endswith(normalized):
return group
raise ValueError(f"Group not found: {normalized}")
def read_payload():
if len(sys.argv) > 1 and sys.argv[1].strip():
return json.loads(sys.argv[1])
@@ -123,11 +158,50 @@ def main():
emit({"ok": True, "data": groups})
return 0
if command == "create-entry":
entry_input = payload.get("entry", {})
title = normalize_path(entry_input.get("title"))
if not title:
emit({"ok": False, "error": "Missing entry title"})
return 1
group = resolve_group_by_path(db, entry_input.get("groupPath"))
created = db.add_entry(
group,
title,
normalize_path(entry_input.get("username")),
normalize_path(entry_input.get("password")),
normalize_path(entry_input.get("url")),
normalize_path(entry_input.get("notes")),
)
save_db(db)
emit({"ok": True, "data": entry_to_dict(created)})
return 0
if command == "create-group":
group_input = payload.get("group", {})
name = normalize_path(group_input.get("name"))
if not name:
emit({"ok": False, "error": "Missing group name"})
return 1
parent = resolve_group_by_path(db, group_input.get("path"))
created_group = db.add_group(parent, name)
save_db(db)
emit({"ok": True, "data": group_to_dict(created_group)})
return 0
if command == "save":
save_db(db)
emit({"ok": True, "data": None})
return 0
emit({"ok": False, "error": f"Unknown command: {command}"})
return 1
except ValueError as exc:
emit({"ok": False, "error": f"Invalid KeePass request: {exc}"})
return 1
except Exception as exc:
emit({"ok": False, "error": str(exc)})
emit({"ok": False, "error": f"KeePass backend error: {exc}"})
return 1
+18 -1
View File
@@ -25,10 +25,27 @@ export type KeePassFindQuery = {
groupPath?: string;
};
export type KeePassEntryInput = {
title: string;
username?: string;
password?: string;
url?: string;
notes?: string;
groupPath?: string;
};
export type KeePassGroupInput = {
name: string;
path?: string;
};
export type KeePassCommand =
| { command: "list-entries" }
| { command: "find-entries"; query: KeePassFindQuery }
| { command: "list-groups" };
| { command: "list-groups" }
| { command: "create-entry"; entry: KeePassEntryInput }
| { command: "create-group"; group: KeePassGroupInput }
| { command: "save" };
export type KeePassResponse<T> =
| {
+64 -1
View File
@@ -1,5 +1,8 @@
import { expect, test } from "bun:test";
import { readFile } from "node:fs/promises";
import { copyFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { openKeePassDatabase } from "../../src/keepass";
type FixtureEntry = {
@@ -25,6 +28,16 @@ type FixtureData = {
const FIXTURE_PATH = "tests/fixtures/data.kdbx";
const FIXTURE_DATA_PATH = "tests/fixtures/data.kdbx.json";
async function withTempCopy<T>(filePath: string, fn: (tempPath: string) => Promise<T>): Promise<T> {
const tempPath = join(tmpdir(), `ts-pykeepass-wrapper-${randomUUID()}.kdbx`);
await copyFile(filePath, tempPath);
try {
return await fn(tempPath);
} finally {
await rm(tempPath, { force: true });
}
}
async function ensurePyKeePass(): Promise<boolean> {
const python = process.env.PYTHON_PATH ?? ".venv/bin/python3";
const child = Bun.spawn([python, "-c", "import pykeepass; print('ok')"], {
@@ -125,6 +138,56 @@ test("finds entries in the bundled data fixture", async () => {
expect(entries[0]?.groupPath).toBe("Folder1");
});
test("creates entries in a temporary copy of the bundled fixture and persists them", async () => {
const [{ password }, pykeepassReady] = await Promise.all([
readFile(FIXTURE_DATA_PATH, "utf8").then((raw) => JSON.parse(raw) as FixtureData),
ensurePyKeePass(),
]);
if (!pykeepassReady) {
console.log("Skipping integration test: pykeepass is not installed");
return;
}
await withTempCopy(FIXTURE_PATH, async (tempPath) => {
const db = openKeePassDatabase(tempPath, { password });
const createdEntry = await db.createEntry({
title: "TempEntry",
username: "temp-user",
password: "temp-pass",
});
expect(createdEntry.title).toBe("TempEntry");
expect(createdEntry.username).toBe("temp-user");
const persisted = await db.findEntries({ title: "TempEntry" });
expect(persisted).toHaveLength(1);
expect(persisted[0]?.username).toBe("temp-user");
});
});
test("creates nested groups on a temporary copy", async () => {
const [{ password }, pykeepassReady] = await Promise.all([
readFile(FIXTURE_DATA_PATH, "utf8").then((raw) => JSON.parse(raw) as FixtureData),
ensurePyKeePass(),
]);
if (!pykeepassReady) {
console.log("Skipping integration test: pykeepass is not installed");
return;
}
await withTempCopy(FIXTURE_PATH, async (tempPath) => {
const db = openKeePassDatabase(tempPath, { password });
const createdGroup = await db.createGroup({ name: "Nested", path: "Folder1" });
expect(createdGroup.path).toBe("Folder1/Nested");
const groups = await db.listGroups();
expect(groups.some((group) => group.path === "Folder1/Nested")).toBe(true);
});
});
test("uses the JSON fixture content as the source of truth for expectations", async () => {
const { content } = JSON.parse(await readFile(FIXTURE_DATA_PATH, "utf8")) as FixtureData;
+227 -19
View File
@@ -1,5 +1,5 @@
import { describe, expect, mock, test } from "bun:test";
import { KeePassDatabase } from "../../src/keepass";
import { KeePassDatabase, openKeePassDatabase } from "../../src/keepass";
const spawnMock = mock(() => {
throw new Error("spawn should be mocked per test");
@@ -9,26 +9,11 @@ mock.module("node:child_process", () => ({
spawn: spawnMock,
}));
describe("KeePassDatabase", () => {
test("listEntries parses successful bridge response", async () => {
function mockSuccessfulBridgeResponse(data: unknown) {
spawnMock.mockImplementation(() => {
const listeners: Record<string, ((chunk: Buffer | string) => void)[]> = {};
const child = {
stdin: {
write: () => undefined,
end: () => {
listeners.close?.forEach((cb) => cb(Buffer.from("")));
},
},
stdout: {
on: (event: string, cb: (chunk: Buffer | string) => void) => {
listeners[event] ??= [];
listeners[event].push(cb);
if (event === "data") {
cb(JSON.stringify({ ok: true, data: [{ title: "Entry" }] }));
}
},
},
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));
@@ -36,6 +21,11 @@ describe("KeePassDatabase", () => {
};
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();
@@ -60,4 +50,222 @@ describe("KeePassDatabase", () => {
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
await expect(db.listEntries()).rejects.toThrow("boom");
});
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;
});
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
await expect(db.listEntries()).rejects.toThrow("bridge failed");
});
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"));
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;
});
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" });
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",
});
});
test("openKeePassDatabase returns a KeePassDatabase instance", () => {
const db = openKeePassDatabase("db.kdbx", { password: "secret" });
expect(db).toBeInstanceOf(KeePassDatabase);
});
});
+3 -2
View File
@@ -8,8 +8,9 @@
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"esModuleInterop": true
"esModuleInterop": true,
"types": ["bun-types"]
},
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}