Compare commits
7 Commits
9496c07049
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 210f7b414b | |||
| 5fa30414d7 | |||
| ee0e2c85f4 | |||
| 8e990cb1b4 | |||
| 2d444e9a8b | |||
| da0b396bf8 | |||
| 89ba04d61a |
+3
-1
@@ -30,4 +30,6 @@ __pycache__/
|
||||
.mypy_cache/
|
||||
|
||||
# Local project memory/state
|
||||
.memory/
|
||||
# Keep memory files tracked
|
||||
!.memory/
|
||||
!.memory/**
|
||||
|
||||
+52
-20
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "kdbx-lib",
|
||||
"name": "ts-pykeepass-wrapper",
|
||||
"packageManager": "bun@1.0.0",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
|
||||
+28
-16
@@ -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
@@ -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
@@ -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> =
|
||||
| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+234
-26
@@ -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,33 +9,23 @@ 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 () => {
|
||||
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" }] }));
|
||||
}
|
||||
},
|
||||
},
|
||||
stderr: { on: () => undefined },
|
||||
on: (event: string, cb: (code?: number | null) => void) => {
|
||||
if (event === "close") queueMicrotask(() => cb(0));
|
||||
},
|
||||
};
|
||||
return child as never;
|
||||
});
|
||||
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
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user