9 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
matmoul 9496c07049 feat: add publish script to merge dev changes into main 2026-05-10 00:02:04 +02:00
matmoul 4cb568c326 fix: default bridge and tests to project venv Python
Use .venv/bin/python3 by default, with PYTHON_PATH as an override, and update the setup script and docs to match the new virtualenv-based workflow.
2026-05-10 00:00:56 +02:00
13 changed files with 580 additions and 134 deletions
+3 -1
View File
@@ -30,4 +30,6 @@ __pycache__/
.mypy_cache/ .mypy_cache/
# Local project memory/state # Local project memory/state
.memory/ # Keep memory files tracked
!.memory/
!.memory/**
+43 -49
View File
@@ -1,58 +1,52 @@
# kdbx-lib # Project
TypeScript wrapper around `pykeepass` for read-only access to KeePass `.kdbx` files. ## Goal
Provide a TypeScript wrapper around KeePass `.kdbx` databases using a Python bridge powered by `pykeepass`.
## Architecture ## 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: TypeScript ## Public API
- Runtime backend: Python 3 - `openKeePassDatabase(path, options)`
- Bridge: `src/python/bridge.py` - `KeePassDatabase.listEntries()`
- Transport: JSON over stdin/stdout - `KeePassDatabase.findEntries(query)`
- Backend library: `pykeepass` - `KeePassDatabase.listGroups()`
- `KeePassDatabase.createEntry(entry)`
- `KeePassDatabase.createGroup(group)`
- `KeePassDatabase.save()`
- `KeePassDatabase.close()` is a no-op.
## Requirements ## 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`.
- Node.js or Bun ## Runtime details
- Python 3 - Python path defaults to `.venv/bin/python3`.
- `pykeepass` installed in the Python environment used by the bridge - It can be overridden with `PYTHON_PATH`.
- A project-local `.venv` works well - `bun run setup:python` creates `.venv` if needed and installs `pykeepass`.
- The bridge also works with an existing project-local virtual environment.
## Python setup ## 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`.
Install dependencies with: ## Main scripts
- `bun run test` / `bun test`
- `bun run src/example.ts`
- `bun run src/test-integration.ts`
- `bun run setup:python`
```bash ## Current direction
bun run setup:python Keep improving failure-path coverage, keep write support minimal and predictable, and continue validating persistence on temporary copies and nested group behavior.
```
Manual alternative:
```bash
python3 -m pip install pykeepass
```
## Core behavior
- Read-only library; it does not modify databases.
- `openKeePassDatabase(path, options)` opens a database through the Python bridge.
- `listEntries()` returns all entry fields exposed by the bridge: `title`, `username`, `password`, `url`, `notes`, `groupPath`, and `otp` when present.
- `findEntries(query)` performs partial matching and returns full entries.
- `listGroups()` returns group names and paths.
- `close()` is currently a no-op.
## Fixture facts
- Bundled fixtures: `tests/fixtures/data.kdbx` and `tests/fixtures/empty.kdbx`
- Fixture passwords and expected content live in companion JSON files
- `data.kdbx` contains four entries: `root`, `otp1`, `f1-item1`, `f2-item1`
- The fixture tree is `Racine/ -> root, otp1, Folder1/ -> f1-item1, Folder2/ -> f2-item1`
- Integration tests cover entries, groups, and the `otp1` OTP/TOTP value
- Canonical OTP value is the full `otpauth://...` URI returned by `pykeepass`
## Notes
- The bridge currently launches a Python process per call; simple but expensive.
- Errors from the bridge are propagated to TypeScript, including exit code when available.
- The API is still flatter than the real KeePass model.
- More failure-path tests are needed.
- Future improvement: a persistent Python process if performance becomes important.
+6 -22
View File
@@ -1,24 +1,8 @@
# State # State
## Current focus - Hardened bridge error handling and nested group path resolution.
Read-only TypeScript wrapper around `pykeepass` via a Python JSON bridge. - Added unit coverage for invalid JSON, empty output, nested group path forwarding, and keyFile payloads.
- Added integration coverage for creating groups on temporary copies.
## Current API - Latest test run passed: 20 tests, 0 failures.
- `openKeePassDatabase(path, options)` - VS Code red squiggles on Bun/Node imports were addressed by including `bun-types` in `tsconfig.json` and covering `tests/**/*.ts`.
- `listEntries()` - Project renamed to ts-pykeepass-wrapper; current focus remains the TypeScript wrapper + Python bridge for KeePass.
- `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.
+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.
+37 -10
View File
@@ -1,21 +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 ## Overview
This project uses: This project uses:
- TypeScript as the public API - TypeScript as the public API
- Python as the runtime backend - 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 ## Requirements
- Node.js or Bun - Node.js or Bun
- Python 3 - Python 3
- `pykeepass` installed in the Python environment used by the bridge (the project provides `bun run setup:python`) - `pykeepass` installed in the Python environment used by the bridge (the project provides `bun run setup:python`)
- The bridge defaults to `.venv/bin/python3`, or you can override with `PYTHON_PATH`
## Python setup ## Python setup
@@ -28,10 +29,10 @@ bun run setup:python
If you prefer manual installation: If you prefer manual installation:
```bash ```bash
python3 -m pip install pykeepass python3 -m venv .venv && .venv/bin/pip install pykeepass
``` ```
The bridge also works with a project-local virtual environment such as `.venv` if you want to pin Python dependencies. The bridge also works with a project-local virtual environment such as `.venv`.
## Usage ## Usage
@@ -61,7 +62,7 @@ bun run src/example.ts
Creates a database wrapper. Creates a database wrapper.
#### Options #### Options
- `password`: KeePass master password, read from the fixture JSON in examples/tests when applicable - `password`: KeePass master password
- `keyFile`: optional key file path - `keyFile`: optional key file path
### `listEntries()` ### `listEntries()`
@@ -73,6 +74,31 @@ Finds entries by partial match and returns the full entry objects.
### `listGroups()` ### `listGroups()`
Returns all groups, including their names and paths. 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()` ### `close()`
No-op for now. No-op for now.
@@ -80,7 +106,8 @@ No-op for now.
- The bridge currently launches a Python process per call. - The bridge currently launches a Python process per call.
- This is simple and robust for a first version. - 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. - 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. - Write operations currently open, modify, and save the database per command.
- Integration tests validate the bundled `data.kdbx` entry-by-entry and group-by-group against `tests/fixtures/data.kdbx.json`. - 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.
Executable
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
set -e
current_branch=$(git branch --show-current)
if [ -z "$current_branch" ]; then
echo "Erreur : impossible de détecter la branche courante."
exit 1
fi
if [ "$current_branch" = "main" ]; then
echo "Erreur : ce script ne peut pas être exécuté depuis la branche main."
exit 1
fi
start_branch="$current_branch"
cleanup() {
if [ "$(git branch --show-current)" != "$start_branch" ]; then
git checkout "$start_branch" >/dev/null 2>&1 || true
fi
}
merge_or_fail() {
source_branch="$1"
target_branch="$2"
if ! git merge "$source_branch"; then
echo "Erreur : conflit lors du merge de $source_branch vers $target_branch."
echo "Résolvez les conflits manuellement, puis relancez le script."
exit 1
fi
}
trap cleanup EXIT
echo "Branche courante détectée : $current_branch"
read -r -p "Confirmer l'exécution sur cette branche ? [y/N] " confirm
case "$confirm" in
y|Y|yes|YES)
;;
*)
echo "Annulé."
exit 1
;;
esac
if [ "$current_branch" != "dev" ]; then
git checkout dev
merge_or_fail "$current_branch" "dev"
git push
current_branch="dev"
fi
git checkout main
merge_or_fail dev main
git push
git checkout dev
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"name": "kdbx-lib", "name": "ts-pykeepass-wrapper",
"packageManager": "bun@1.0.0", "packageManager": "bun@1.0.0",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
@@ -10,7 +10,7 @@
"test": "bun test", "test": "bun test",
"test:unit": "bun test", "test:unit": "bun test",
"test:integration": "bun run src/test-integration.ts", "test:integration": "bun run src/test-integration.ts",
"setup:python": "python3 -m pip install pykeepass" "setup:python": "test -x .venv/bin/python3 || python3 -m venv .venv && .venv/bin/pip install pykeepass"
}, },
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
+28 -16
View File
@@ -3,18 +3,20 @@ import { fileURLToPath } from "node:url";
import type { import type {
KeePassCommand, KeePassCommand,
KeePassEntry, KeePassEntry,
KeePassEntryInput,
KeePassFindQuery, KeePassFindQuery,
KeePassGroup, KeePassGroup,
KeePassGroupInput,
KeePassOpenOptions, KeePassOpenOptions,
KeePassResponse, KeePassResponse,
} from "./types"; } from "./types";
export class KeePassDatabase { export class KeePassDatabase {
constructor( constructor(
private readonly path: string, private path: string,
private readonly options: KeePassOpenOptions, private options: KeePassOpenOptions,
private readonly pythonPath = "python3", private pythonPath = process.env.PYTHON_PATH ?? ".venv/bin/python3",
private readonly bridgePath = new URL("./python/bridge.py", import.meta.url) private bridgePath = new URL("./python/bridge.py", import.meta.url)
) {} ) {}
async listEntries(): Promise<KeePassEntry[]> { async listEntries(): Promise<KeePassEntry[]> {
@@ -32,8 +34,19 @@ export class KeePassDatabase {
return response; 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> { async close(): Promise<void> {
// No persistent process is kept alive yet.
return; return;
} }
@@ -47,19 +60,23 @@ export class KeePassDatabase {
const bridgeFile = fileURLToPath(this.bridgePath); const bridgeFile = fileURLToPath(this.bridgePath);
const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => { const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {
const child = spawn(this.pythonPath, [bridgeFile], { const child = spawn(this.pythonPath, [bridgeFile], { stdio: ["pipe", "pipe", "pipe"] });
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
let settled = false; let settled = false;
const fail = (error: unknown) => {
if (!settled) {
settled = true;
reject(error instanceof Error ? error : new Error(String(error)));
}
};
try { try {
child.stdin.write(payload); child.stdin.write(payload);
child.stdin.end(); child.stdin.end();
} catch (error) { } catch (error) {
reject(error); fail(error);
} }
child.stdout.on("data", (chunk) => { child.stdout.on("data", (chunk) => {
@@ -70,12 +87,7 @@ export class KeePassDatabase {
stderr += chunk.toString(); stderr += chunk.toString();
}); });
child.on("error", (error) => { child.on("error", fail);
if (!settled) {
settled = true;
reject(error);
}
});
child.on("close", (code) => { child.on("close", (code) => {
if (!settled) { 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) 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(): def read_payload():
if len(sys.argv) > 1 and sys.argv[1].strip(): if len(sys.argv) > 1 and sys.argv[1].strip():
return json.loads(sys.argv[1]) return json.loads(sys.argv[1])
@@ -123,11 +158,50 @@ def main():
emit({"ok": True, "data": groups}) emit({"ok": True, "data": groups})
return 0 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}"}) emit({"ok": False, "error": f"Unknown command: {command}"})
return 1 return 1
except ValueError as exc:
emit({"ok": False, "error": f"Invalid KeePass request: {exc}"})
return 1
except Exception as exc: except Exception as exc:
emit({"ok": False, "error": str(exc)}) emit({"ok": False, "error": f"KeePass backend error: {exc}"})
return 1 return 1
+18 -1
View File
@@ -25,10 +25,27 @@ export type KeePassFindQuery = {
groupPath?: string; 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 = export type KeePassCommand =
| { command: "list-entries" } | { command: "list-entries" }
| { command: "find-entries"; query: KeePassFindQuery } | { 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> = export type KeePassResponse<T> =
| { | {
+67 -4
View File
@@ -1,5 +1,8 @@
import { expect, test } from "bun:test"; 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"; import { openKeePassDatabase } from "../../src/keepass";
type FixtureEntry = { type FixtureEntry = {
@@ -25,8 +28,19 @@ type FixtureData = {
const FIXTURE_PATH = "tests/fixtures/data.kdbx"; const FIXTURE_PATH = "tests/fixtures/data.kdbx";
const FIXTURE_DATA_PATH = "tests/fixtures/data.kdbx.json"; 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> { async function ensurePyKeePass(): Promise<boolean> {
const child = Bun.spawn(["python3", "-c", "import pykeepass; print('ok')"], { const python = process.env.PYTHON_PATH ?? ".venv/bin/python3";
const child = Bun.spawn([python, "-c", "import pykeepass; print('ok')"], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}); });
@@ -95,7 +109,7 @@ test("lists the groups from the bundled data fixture", async () => {
} }
const db = openKeePassDatabase(FIXTURE_PATH, { password }); const db = openKeePassDatabase(FIXTURE_PATH, { password });
const groups = await db.listGroups(); const groups = await db.listGroups();
expect(groups).toEqual([ expect(groups).toEqual([
{ name: "Racine", path: "" }, { name: "Racine", path: "" },
{ name: "Folder1", path: "Folder1" }, { name: "Folder1", path: "Folder1" },
@@ -124,6 +138,56 @@ test("finds entries in the bundled data fixture", async () => {
expect(entries[0]?.groupPath).toBe("Folder1"); 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 () => { 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; const { content } = JSON.parse(await readFile(FIXTURE_DATA_PATH, "utf8")) as FixtureData;
@@ -145,4 +209,3 @@ test("uses the JSON fixture content as the source of truth for expectations", as
"\t\t- f2-item1", "\t\t- f2-item1",
]); ]);
}); });
+227 -19
View File
@@ -1,5 +1,5 @@
import { describe, expect, mock, test } from "bun:test"; import { describe, expect, mock, test } from "bun:test";
import { KeePassDatabase } from "../../src/keepass"; import { KeePassDatabase, openKeePassDatabase } from "../../src/keepass";
const spawnMock = mock(() => { const spawnMock = mock(() => {
throw new Error("spawn should be mocked per test"); throw new Error("spawn should be mocked per test");
@@ -9,26 +9,11 @@ mock.module("node:child_process", () => ({
spawn: spawnMock, spawn: spawnMock,
})); }));
describe("KeePassDatabase", () => { function mockSuccessfulBridgeResponse(data: unknown) {
test("listEntries parses successful bridge response", async () => {
spawnMock.mockImplementation(() => { spawnMock.mockImplementation(() => {
const listeners: Record<string, ((chunk: Buffer | string) => void)[]> = {};
const child = { const child = {
stdin: { stdin: { write: () => undefined, end: () => undefined },
write: () => undefined, stdout: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb(JSON.stringify({ ok: true, data })) },
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 }, stderr: { on: () => undefined },
on: (event: string, cb: (code?: number | null) => void) => { on: (event: string, cb: (code?: number | null) => void) => {
if (event === "close") queueMicrotask(() => cb(0)); if (event === "close") queueMicrotask(() => cb(0));
@@ -36,6 +21,11 @@ describe("KeePassDatabase", () => {
}; };
return child as never; 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 db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
const entries = await db.listEntries(); 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")); const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
await expect(db.listEntries()).rejects.toThrow("boom"); 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", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true "esModuleInterop": true,
"types": ["bun-types"]
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"] "exclude": ["dist", "node_modules"]
} }