diff --git a/.memory/project.md b/.memory/project.md index 52dbf9a..3f999cb 100644 --- a/.memory/project.md +++ b/.memory/project.md @@ -9,6 +9,7 @@ Provide a TypeScript wrapper around KeePass `.kdbx` databases using a Python bri - 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. +- Coverage now includes `keyFile` payload propagation and core API smoke checks. ## Public API - `openKeePassDatabase(path, options)` @@ -19,6 +20,7 @@ Provide a TypeScript wrapper around KeePass `.kdbx` databases using a Python bri - `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`. @@ -34,9 +36,10 @@ Provide a TypeScript wrapper around KeePass `.kdbx` databases using a Python bri ## 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. +- 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, and basic write persistence on a temporary copy 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` diff --git a/.memory/state.md b/.memory/state.md index da7d6cc..8c6c2ba 100644 --- a/.memory/state.md +++ b/.memory/state.md @@ -1,28 +1,5 @@ # State -## Current focus -Read-only TypeScript wrapper around KeePass `.kdbx` databases through a Python JSON bridge. - -## Current API -- `openKeePassDatabase(path, options)` -- `listEntries()` -- `findEntries(query)` -- `listGroups()` -- `close()` is a no-op - -## Runtime model -- TypeScript spawns Python per request -- Python uses `pykeepass` -- JSON is exchanged over stdin/stdout -- Errors from the bridge, missing files, invalid JSON, and backend exceptions are surfaced to TypeScript -- Python defaults to `.venv/bin/python3`, overridable with `PYTHON_PATH` - -## Current fixture/test status -- Bundled fixtures: `tests/fixtures/data.kdbx` and `tests/fixtures/empty.kdbx` -- `tests/fixtures/data.kdbx.json` stores the password and expected tree/content -- Unit tests mock `node:child_process.spawn` -- Integration tests validate entries, groups, partial search, and OTP/TOTP output for `data.kdbx` -- Integration tests skip when `pykeepass` is unavailable - -## Next step -Keep tightening failure-path coverage and keep the API minimal unless a concrete need appears. +- Added failure-path, command-forwarding, and keyFile payload unit tests for the bridge. +- Latest unit and full test runs passed. +- Current focus remains the TypeScript wrapper + Python bridge for KeePass. diff --git a/tests/unit/keepass.test.ts b/tests/unit/keepass.test.ts index c88c60d..d127050 100644 --- a/tests/unit/keepass.test.ts +++ b/tests/unit/keepass.test.ts @@ -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"); @@ -51,6 +51,57 @@ describe("KeePassDatabase", () => { 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 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" }); @@ -78,4 +129,98 @@ describe("KeePassDatabase", () => { 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); + }); });