commit 0d25e52ebc951acce56426f46164a4a0458e5e10 Author: MatMoul Date: Sat May 9 23:50:24 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d365e93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ +**/node_modules/ + +# Python virtualenv +.venv/ +venv/ +.env +.env.* + +# Build outputs +dist/ +build/ +coverage/ +*.tsbuildinfo + +# Logs +*.log + +# OS / editor +.DS_Store +Thumbs.db +.vscode/ +.idea/ + +# Python cache +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ + +# Local project memory/state +.memory/ diff --git a/.memory/project.md b/.memory/project.md new file mode 100644 index 0000000..47a9c1b --- /dev/null +++ b/.memory/project.md @@ -0,0 +1,58 @@ +# kdbx-lib + +TypeScript wrapper around `pykeepass` for read-only access to KeePass `.kdbx` files. + +## Architecture + +- Public API: TypeScript +- Runtime backend: Python 3 +- Bridge: `src/python/bridge.py` +- Transport: JSON over stdin/stdout +- Backend library: `pykeepass` + +## Requirements + +- Node.js or Bun +- Python 3 +- `pykeepass` installed in the Python environment used by the bridge +- A project-local `.venv` works well + +## Python setup + +Install dependencies with: + +```bash +bun run setup:python +``` + +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. diff --git a/.memory/state.md b/.memory/state.md new file mode 100644 index 0000000..9307470 --- /dev/null +++ b/.memory/state.md @@ -0,0 +1,24 @@ +# 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..67667d7 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# kdbx-lib + +TypeScript wrapper around `pykeepass` for reading KeePass `.kdbx` files. + +## Overview + +This project uses: +- TypeScript as the public API +- Python as the runtime backend +- `pykeepass` to read KeePass databases + +The TypeScript layer launches a Python bridge 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`) + +## Python setup + +Install `pykeepass` in the Python environment used by the bridge: + +```bash +bun run setup:python +``` + +If you prefer manual installation: + +```bash +python3 -m pip install pykeepass +``` + +The bridge also works with a project-local virtual environment such as `.venv` if you want to pin Python dependencies. + +## Usage + +```ts +import { openKeePassDatabase } from "./src/keepass"; + +const db = openKeePassDatabase("tests/fixtures/data.kdbx", { + password: "123", +}); + +const entries = await db.listEntries(); +console.log(entries); +``` + +## Example + +Run the example using the bundled data fixture credentials: + +```bash +bun run src/example.ts +``` + +## API + +### `openKeePassDatabase(path, options)` + +Creates a database wrapper. + +#### Options +- `password`: KeePass master password, read from the fixture JSON in examples/tests when applicable +- `keyFile`: optional key file path + +### `listEntries()` +Returns all entries in the database, with every entry field exposed by the bridge (`title`, `username`, `password`, `url`, `notes`, `groupPath`, `otp` when present). + +### `findEntries(query)` +Finds entries by partial match and returns the full entry objects. + +### `listGroups()` +Returns all groups, including their names and paths. + +### `close()` +No-op for now. + +## Notes + +- 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. +- 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`. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..2a9d533 --- /dev/null +++ b/bun.lock @@ -0,0 +1,22 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "kdbx-lib", + "devDependencies": { + "bun-types": "^1.1.0", + "typescript": "^5.5.0", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..eafb5ac --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "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 pip install pykeepass" + }, + "dependencies": {}, + "devDependencies": { + "typescript": "^5.5.0", + "bun-types": "^1.1.0" + } +} diff --git a/src/example.ts b/src/example.ts new file mode 100644 index 0000000..96b79bb --- /dev/null +++ b/src/example.ts @@ -0,0 +1,15 @@ +import { readFile } from "node:fs/promises"; +import { openKeePassDatabase } from "./keepass"; + +async function main() { + const { password } = JSON.parse(await readFile("tests/fixtures/data.kdbx.json", "utf8")) as { password: string }; + + const db = openKeePassDatabase("tests/fixtures/data.kdbx", { password }); + const entries = await db.listEntries(); + console.log(entries); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c40ff4e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,9 @@ +export { KeePassDatabase, openKeePassDatabase } from "./keepass"; +export type { + KeePassCommand, + KeePassEntry, + KeePassFindQuery, + KeePassGroup, + KeePassOpenOptions, + KeePassResponse, +} from "./types"; \ No newline at end of file diff --git a/src/keepass.ts b/src/keepass.ts new file mode 100644 index 0000000..5495a7b --- /dev/null +++ b/src/keepass.ts @@ -0,0 +1,110 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import type { + KeePassCommand, + KeePassEntry, + KeePassFindQuery, + KeePassGroup, + KeePassOpenOptions, + KeePassResponse, +} from "./types"; + +export class KeePassDatabase { + constructor( + private readonly path: string, + private readonly options: KeePassOpenOptions, + private readonly pythonPath = "python3", + private readonly bridgePath = new URL("./python/bridge.py", import.meta.url) + ) {} + + async listEntries(): Promise { + const response = await this.run({ command: "list-entries" }); + return response; + } + + async findEntries(query: KeePassFindQuery): Promise { + const response = await this.run({ command: "find-entries", query }); + return response; + } + + async listGroups(): Promise { + const response = await this.run({ command: "list-groups" }); + return response; + } + + async close(): Promise { + // No persistent process is kept alive yet. + return; + } + + private async run(command: KeePassCommand): Promise { + const payload = JSON.stringify({ + ...command, + path: this.path, + password: this.options.password, + keyFile: this.options.keyFile, + }); + + 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"], + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + + try { + child.stdin.write(payload); + child.stdin.end(); + } catch (error) { + reject(error); + } + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + child.on("error", (error) => { + if (!settled) { + settled = true; + reject(error); + } + }); + + child.on("close", (code) => { + if (!settled) { + settled = true; + resolve({ stdout, stderr, code: code ?? 1 }); + } + }); + }); + + const output = result.stdout.trim(); + if (!output) { + throw new Error(result.stderr || `Empty response from Python bridge (exit code ${result.code})`); + } + + let parsed: KeePassResponse; + try { + parsed = JSON.parse(output) as KeePassResponse; + } catch (error) { + throw new Error(`Invalid JSON from Python bridge: ${error instanceof Error ? error.message : String(error)}`); + } + + if (!parsed.ok) { + throw new Error(parsed.error || result.stderr || `KeePass bridge error (exit code ${result.code})`); + } + + return parsed.data; + } +} + +export function openKeePassDatabase(path: string, options: KeePassOpenOptions) { + return new KeePassDatabase(path, options); +} \ No newline at end of file diff --git a/src/python/bridge.py b/src/python/bridge.py new file mode 100644 index 0000000..e47d457 --- /dev/null +++ b/src/python/bridge.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 + +import json +import sys +from pathlib import Path + +try: + from pykeepass import PyKeePass +except Exception as exc: + print(json.dumps({"ok": False, "error": f"Failed to import pykeepass: {exc}"})) + sys.exit(1) + + +def path_to_string(path): + if path is None: + return "" + + if isinstance(path, str): + return path + + if isinstance(path, (list, tuple)): + parts = [] + for segment in path: + if hasattr(segment, "name") and segment.name: + parts.append(segment.name) + elif segment: + parts.append(str(segment)) + return "/".join(parts) + + return str(path) + + +def entry_to_dict(entry): + result = { + "title": entry.title or "", + "username": entry.username or "", + "password": entry.password or "", + "url": entry.url or "", + "notes": entry.notes or "", + "groupPath": path_to_string(entry.group.path if entry.group else ""), + } + + otp = getattr(entry, "otp", None) + if otp: + result["otp"] = otp + + return result + + +def group_to_dict(group): + return { + "name": group.name or "", + "path": path_to_string(group.path), + } + + +def load_db(db_path, password, key_file=None): + if key_file: + return PyKeePass(db_path, password=password, keyfile=key_file) + return PyKeePass(db_path, password=password) + + +def read_payload(): + if len(sys.argv) > 1 and sys.argv[1].strip(): + return json.loads(sys.argv[1]) + + return json.load(sys.stdin) + + +def emit(payload): + print(json.dumps(payload), flush=True) + + +def main(): + try: + payload = read_payload() + except Exception as exc: + emit({"ok": False, "error": f"Invalid input payload: {exc}"}) + return 1 + + command = payload.get("command") + db_path = payload.get("path") + password = payload.get("password") + key_file = payload.get("keyFile") + + if not db_path or not password: + emit({"ok": False, "error": "Missing path or password"}) + return 1 + + if not Path(db_path).exists(): + emit({"ok": False, "error": f"Database not found: {db_path}"}) + return 1 + + try: + db = load_db(db_path, password, key_file) + + if command == "list-entries": + entries = [entry_to_dict(entry) for entry in db.entries] + emit({"ok": True, "data": entries}) + return 0 + + if command == "find-entries": + query = payload.get("query", {}) + results = [] + for entry in db.entries: + entry_group_path = path_to_string(entry.group.path if entry.group else "") + if query.get("title") and query["title"] not in (entry.title or ""): + continue + if query.get("username") and query["username"] not in (entry.username or ""): + continue + if query.get("url") and query["url"] not in (entry.url or ""): + continue + if query.get("groupPath") and query["groupPath"] not in entry_group_path: + continue + results.append(entry_to_dict(entry)) + emit({"ok": True, "data": results}) + return 0 + + if command == "list-groups": + groups = [] + for group in db.groups: + groups.append(group_to_dict(group)) + emit({"ok": True, "data": groups}) + return 0 + + emit({"ok": False, "error": f"Unknown command: {command}"}) + return 1 + + except Exception as exc: + emit({"ok": False, "error": str(exc)}) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/src/test-integration.ts b/src/test-integration.ts new file mode 100644 index 0000000..a534db0 --- /dev/null +++ b/src/test-integration.ts @@ -0,0 +1,16 @@ +import { readFile } from "node:fs/promises"; +import { openKeePassDatabase } from "./keepass"; + +async function main() { + const { password } = JSON.parse(await readFile("tests/fixtures/data.kdbx.json", "utf8")) as { password: string }; + + const db = openKeePassDatabase("tests/fixtures/data.kdbx", { password }); + const entries = await db.listEntries(); + + console.log(JSON.stringify({ ok: true, count: entries.length }, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..745de84 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,41 @@ +export type KeePassEntry = { + title: string; + username: string; + password: string; + url: string; + notes: string; + groupPath?: string; + otp?: string; +}; + +export type KeePassGroup = { + name: string; + path: string; +}; + +export type KeePassOpenOptions = { + password: string; + keyFile?: string; +}; + +export type KeePassFindQuery = { + title?: string; + username?: string; + url?: string; + groupPath?: string; +}; + +export type KeePassCommand = + | { command: "list-entries" } + | { command: "find-entries"; query: KeePassFindQuery } + | { command: "list-groups" }; + +export type KeePassResponse = + | { + ok: true; + data: T; + } + | { + ok: false; + error: string; + }; \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..f71a70b --- /dev/null +++ b/tests/README.md @@ -0,0 +1,15 @@ +# Tests + +This directory is organized as follows: + +- `tests/unit/`: fast unit tests with mocks +- `tests/integration/`: optional integration tests that may require `pykeepass` and a Python environment +- `tests/fixtures/`: bundled KeePass databases and matching JSON credentials/content files + +## Conventions +- Use `*.test.ts` filenames. +- Keep unit tests isolated and fast. +- Prefer mocking the Python bridge in unit tests. +- Put environment-dependent checks in `tests/integration/`. +- Keep fixture-driven expectations aligned with the matching `*.kdbx.json` file. +- Integration tests should skip or self-report when prerequisites are missing. diff --git a/tests/fixtures/data.kdbx b/tests/fixtures/data.kdbx new file mode 100644 index 0000000..4cabbba Binary files /dev/null and b/tests/fixtures/data.kdbx differ diff --git a/tests/fixtures/data.kdbx.json b/tests/fixtures/data.kdbx.json new file mode 100644 index 0000000..c4c5699 --- /dev/null +++ b/tests/fixtures/data.kdbx.json @@ -0,0 +1,44 @@ +{ + "password": "123", + "content": { + "root": { + "folders": [ + { + "name": "Folder1", + "folders": [], + "entries": [ + { + "title": "f1-item1", + "user": "f1-item1", + "password": "123" + } + ] + }, + { + "name": "Folder2", + "folders": [], + "entries": [ + { + "title": "f2-item1", + "user": "f2-item1", + "password": "123" + } + ] + } + ], + "entries": [ + { + "title": "root", + "user": "root", + "password": "123" + }, + { + "title": "otp1", + "user": "otp1", + "password": "123", + "otp": "otpauth://totp/otp1:otp1?secret=234324AB34%3D%3D%3D%3D%3D%3D&period=30&digits=6&issuer=otp1" + } + ] + } + } +} diff --git a/tests/fixtures/empty.kdbx b/tests/fixtures/empty.kdbx new file mode 100644 index 0000000..65542ce Binary files /dev/null and b/tests/fixtures/empty.kdbx differ diff --git a/tests/fixtures/empty.kdbx.json b/tests/fixtures/empty.kdbx.json new file mode 100644 index 0000000..4d40143 --- /dev/null +++ b/tests/fixtures/empty.kdbx.json @@ -0,0 +1,3 @@ +{ + "password": "123" +} diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..a27a348 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,10 @@ +# Integration Tests + +This directory is reserved for tests that require external dependencies or a Python environment with `pykeepass` installed. + +## Conventions +- Use `*.test.ts` filenames. +- Keep tests here isolated from unit tests. +- Prefer explicit setup/skip logic when runtime dependencies are missing. +- Integration tests should verify the real KeePass bridge when `pykeepass` and fixture credentials are available. +- Prefer fixtures in `tests/fixtures/` and keep expectations aligned with the companion JSON file. diff --git a/tests/integration/pykeepass.test.ts b/tests/integration/pykeepass.test.ts new file mode 100644 index 0000000..9180cdf --- /dev/null +++ b/tests/integration/pykeepass.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { openKeePassDatabase } from "../../src/keepass"; + +type FixtureEntry = { + title: string; + user: string; + password: string; + otp?: string; +}; + +type FixtureFolder = { + name: string; + folders: FixtureFolder[]; + entries: FixtureEntry[]; +}; + +type FixtureData = { + password: string; + content: { + root: FixtureFolder; + }; +}; + +const FIXTURE_PATH = "tests/fixtures/data.kdbx"; +const FIXTURE_DATA_PATH = "tests/fixtures/data.kdbx.json"; + +async function ensurePyKeePass(): Promise { + const child = Bun.spawn(["python3", "-c", "import pykeepass; print('ok')"], { + stdout: "pipe", + stderr: "pipe", + }); + + return child.exited.then((code) => code === 0); +} + +function flattenEntries(folder: FixtureFolder, groupPath = ""): Array { + const ownEntries = folder.entries.map((entry) => ({ ...entry, groupPath })); + const nestedEntries = folder.folders.flatMap((child) => flattenEntries(child, groupPath ? `${groupPath}/${child.name}` : child.name)); + return [...ownEntries, ...nestedEntries]; +} + +function groupTreeToString(folder: FixtureFolder, indent = ""): string[] { + const lines = [`${indent}${folder.name || "Racine"}/`]; + for (const entry of folder.entries) { + lines.push(`${indent} - ${entry.title}`); + } + for (const child of folder.folders) { + lines.push(...groupTreeToString(child, `${indent} `)); + } + return lines; +} + +test("opens the bundled data fixture and exposes all entries and values", async () => { + const [{ password, content }, 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; + } + + const expectedEntries = flattenEntries(content.root); + const db = openKeePassDatabase(FIXTURE_PATH, { password }); + const entries = await db.listEntries(); + + expect(entries).toHaveLength(expectedEntries.length); + expect(entries.map((entry) => entry.title).sort()).toEqual(expectedEntries.map((entry) => entry.title).sort()); + + for (const expected of expectedEntries) { + const actual = entries.find((entry) => entry.title === expected.title); + expect(actual).toBeTruthy(); + expect(actual?.username).toBe(expected.user); + expect(actual?.password).toBe(expected.password); + expect(actual?.notes).toBe(""); + expect(actual?.url).toBe(""); + expect(actual?.groupPath).toBe(expected.groupPath); + if (expected.otp) { + expect(actual?.otp).toBe(expected.otp); + } + } +}); + +test("lists the groups from the bundled data fixture", 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; + } + + const db = openKeePassDatabase(FIXTURE_PATH, { password }); +const groups = await db.listGroups(); + expect(groups).toEqual([ + { name: "Racine", path: "" }, + { name: "Folder1", path: "Folder1" }, + { name: "Folder2", path: "Folder2" }, + ]); +}); + +test("finds entries in the bundled data fixture", 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; + } + + const db = openKeePassDatabase(FIXTURE_PATH, { password }); + const entries = await db.findEntries({ title: "f1-item1" }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.title).toBe("f1-item1"); + expect(entries[0]?.username).toBe("f1-item1"); + expect(entries[0]?.password).toBe("123"); + expect(entries[0]?.groupPath).toBe("Folder1"); +}); + +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; + + expect(content.root.folders).toHaveLength(2); + expect(content.root.entries).toHaveLength(2); + expect(flattenEntries(content.root)).toEqual([ + { title: "root", user: "root", password: "123", groupPath: "" }, + { title: "otp1", user: "otp1", password: "123", otp: "otpauth://totp/otp1:otp1?secret=234324AB34%3D%3D%3D%3D%3D%3D&period=30&digits=6&issuer=otp1", groupPath: "" }, + { title: "f1-item1", user: "f1-item1", password: "123", groupPath: "Folder1" }, + { title: "f2-item1", user: "f2-item1", password: "123", groupPath: "Folder2" }, + ]); + expect(groupTreeToString(content.root)).toEqual([ + "Racine/", + "\t- root", + "\t- otp1", + "\tFolder1/", + "\t\t- f1-item1", + "\tFolder2/", + "\t\t- f2-item1", + ]); +}); + diff --git a/tests/unit/keepass.test.ts b/tests/unit/keepass.test.ts new file mode 100644 index 0000000..7646534 --- /dev/null +++ b/tests/unit/keepass.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, mock, test } from "bun:test"; +import { KeePassDatabase } from "../../src/keepass"; + +const spawnMock = mock(() => { + throw new Error("spawn should be mocked per test"); +}); + +mock.module("node:child_process", () => ({ + spawn: spawnMock, +})); + +describe("KeePassDatabase", () => { + test("listEntries parses successful bridge response", async () => { + spawnMock.mockImplementation(() => { + const listeners: Record 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; + }); + + const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py")); + const entries = await db.listEntries(); + + expect(entries).toEqual([{ title: "Entry" }]); + expect(spawnMock).toHaveBeenCalled(); + }); + + test("throws on bridge error payload", async () => { + spawnMock.mockImplementation(() => { + const child = { + stdin: { write: () => undefined, end: () => undefined }, + stdout: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb('{"ok":false,"error":"boom"}') }, + stderr: { on: () => undefined }, + 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("boom"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a4f2e80 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} \ No newline at end of file