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.
This commit is contained in:
+4
-3
@@ -9,7 +9,8 @@ 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.
|
||||
- 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)`
|
||||
@@ -37,7 +38,7 @@ Provide a TypeScript wrapper around KeePass `.kdbx` databases using a Python bri
|
||||
- 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, and basic write persistence on a temporary copy when `pykeepass` is installed.
|
||||
- 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`.
|
||||
|
||||
@@ -48,4 +49,4 @@ Provide a TypeScript wrapper around KeePass `.kdbx` databases using a Python bri
|
||||
- `bun run setup:python`
|
||||
|
||||
## Current direction
|
||||
Keep improving failure-path coverage, keep write support minimal and predictable, and continue validating persistence on temporary copies.
|
||||
Keep improving failure-path coverage, keep write support minimal and predictable, and continue validating persistence on temporary copies and nested group behavior.
|
||||
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
# State
|
||||
|
||||
- Added failure-path, command-forwarding, and keyFile payload unit tests for the bridge.
|
||||
- Latest unit and full test runs passed.
|
||||
- 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.
|
||||
- Project renamed to ts-pykeepass-wrapper; current focus remains the TypeScript wrapper + Python bridge for KeePass.
|
||||
|
||||
+3
-4
@@ -1,6 +1,5 @@
|
||||
# Todo
|
||||
|
||||
- Improve failure-path coverage for write operations.
|
||||
- Verify and harden nested group path resolution.
|
||||
- Keep integration write tests on temporary copies only.
|
||||
- Keep API minimal and predictable.
|
||||
- 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.
|
||||
|
||||
@@ -85,6 +85,8 @@ Creates a new entry in the target database and persists it immediately.
|
||||
- `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.
|
||||
|
||||
@@ -92,6 +94,8 @@ Creates a new group and persists it immediately.
|
||||
- `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.
|
||||
|
||||
@@ -102,7 +106,7 @@ 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, including invalid or empty output.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
+10
-11
@@ -60,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) => {
|
||||
@@ -83,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) {
|
||||
|
||||
@@ -86,7 +86,8 @@ def resolve_group_by_path(db, group_path):
|
||||
if matching_groups:
|
||||
return matching_groups[0]
|
||||
|
||||
matching_groups = db.find_groups(name=normalized.split("/")[-1])
|
||||
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
|
||||
@@ -196,8 +197,11 @@ def main():
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -166,6 +166,28 @@ test("creates entries in a temporary copy of the bundled fixture and persists th
|
||||
});
|
||||
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -85,6 +85,23 @@ describe("KeePassDatabase", () => {
|
||||
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 = {
|
||||
@@ -122,6 +139,34 @@ describe("KeePassDatabase", () => {
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user