From 0d25e52ebc951acce56426f46164a4a0458e5e10 Mon Sep 17 00:00:00 2001 From: MatMoul Date: Sat, 9 May 2026 23:50:24 +0200 Subject: [PATCH] Initial commit --- .gitignore | 33 +++++++ .memory/project.md | 58 +++++++++++ .memory/state.md | 24 +++++ README.md | 86 ++++++++++++++++ bun.lock | 22 +++++ package.json | 20 ++++ src/example.ts | 15 +++ src/index.ts | 9 ++ src/keepass.ts | 110 +++++++++++++++++++++ src/python/bridge.py | 135 +++++++++++++++++++++++++ src/test-integration.ts | 16 +++ src/types.ts | 41 ++++++++ tests/README.md | 15 +++ tests/fixtures/data.kdbx | Bin 0 -> 2997 bytes tests/fixtures/data.kdbx.json | 44 +++++++++ tests/fixtures/empty.kdbx | Bin 0 -> 1525 bytes tests/fixtures/empty.kdbx.json | 3 + tests/integration/README.md | 10 ++ tests/integration/pykeepass.test.ts | 148 ++++++++++++++++++++++++++++ tests/unit/keepass.test.ts | 63 ++++++++++++ tsconfig.json | 15 +++ 21 files changed, 867 insertions(+) create mode 100644 .gitignore create mode 100644 .memory/project.md create mode 100644 .memory/state.md create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/example.ts create mode 100644 src/index.ts create mode 100644 src/keepass.ts create mode 100644 src/python/bridge.py create mode 100644 src/test-integration.ts create mode 100644 src/types.ts create mode 100644 tests/README.md create mode 100644 tests/fixtures/data.kdbx create mode 100644 tests/fixtures/data.kdbx.json create mode 100644 tests/fixtures/empty.kdbx create mode 100644 tests/fixtures/empty.kdbx.json create mode 100644 tests/integration/README.md create mode 100644 tests/integration/pykeepass.test.ts create mode 100644 tests/unit/keepass.test.ts create mode 100644 tsconfig.json 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 0000000000000000000000000000000000000000..4cabbba7707c05f1325f9c745d5dbf2048679687 GIT binary patch literal 2997 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..65542cec77eadc48e63a4788dc13c9d73aead151 GIT binary patch literal 1525 zcmVpI+V6|L8F1seRj5~2jlr9Gl0002zD4Tu1l@h6!TIFy6 z@oW1FivR!s00BY;0000aRaHqu5C8xG?_+J>j44D*k@u;j1LFz|1pxp607(b{002<{ z00000000F60000@2mk;800004000001OWg508j(~000C4002S(0000}AOHXWkG{@> z9@+8)nZIp``l#FUK_oIb5qZ`!i9&TzTK$1z1OWg509FJ5000vJ000001ONa44GIkk zU=#TDwh_r7GKL*-Fxnjh7cux*E=(wGXJ{mxwevId!^|n*a0CDVz20o5 zi|fFYjo*;gSaE?P4Mfzzpi>ZjMvCqTJ`L4;u@lVFhWkeUuPmNC^SARALg+*Xe19K{ zhHeV*!0ec%W;G{MEBTII6~HL%9KY@lch4`-smwiToyZKdBQ##+p#!>RCliNG2-i5+ z4F1nEMIdul$UgD1-~rqP58ni-A=PuHl_$$6`)n`U87qpOAzE628>4>V61YMfA-}My zGsCsbYJeI4O~)&M$+xrcYS4QzUPh3s1Mf19ikcIvbIc5?qC6!O;WT|MyOKX4#XQ!` z@b4&RfB_LJQ=cKZ9gGZyT5F)JJ1zjLz&X0@TaNzxJ>5b5nD*^m8 zb4p&3L*hO6H0nhpT@Kt9+nT9^5hUCaaJ2ZUxRs{qw=(6p;p&&g$zmQX(t=@040!sW z$(&KLxf^%!(sO@0opAxzGN{(9ynGD5Evp_EiO8?*k8Wf7^jHjm5({lXpP7sevcis( zB)x-V2ovY#SF+c34B8e;60=$`9;-yja_{)kRYc_v^96iNZ#q(`+sUo3+-6ygUu=29 zu=;Y%!aJX2)es#Dm<+|yC3n_KwFfke?+N<_49gq7O0f6gXgRM^Gw?P#iKZh@GpDe& zQVU^L9JwNi#x_=Y32ix*I{p~65d2(^$I8@N+@7Pf^gFIJW@ZJa`tb1Y#1zb{=I*(Q zXmIoxv)hK9yW;~6`UVpjP|uq+WrkxES6Cxg|H+W`N!N-OT0v2FBxl$_o_JaLMjU(v zB#h*Ap+cU$<5lH1WhalD`5VbIH*tblo(mp_KP}VJ^?0*_(;dc*x}T|kXIy&Vt^>hJ zhEJyP5Bhg%HQ7}zoc)DvT`*(eK7;pKMF_$;bIVnuLe_%u&D_^<=pw)F6PM1yj{>01q7r^H4Zbh0jX{Fh{LkMtzE`RK;)4aQM!L%lpxFYmrs|~?pz7wNGkR1fz0L$Pz+qx5dbYGXpw-WDtD9e-Zpa1?3e*gXg`TST*73bZGPuY z;t^F}$(Abn!fY7fZmWev;aDQl6gVKO-?Z>46dVXadDJ}q8rr+*49kXOUvQunV_#n} zhKAU-YLxgj^IwFDq~q@mPYUl^GZV01*nNT_xMLldQjRH3AwF9gyhI%l z%EB?y;rDMI;yB=anCZmULK<1k(e)7iy@e!xqAMO#N5xoN`lDRJE9b*e8U*W52hU1m za5PdnItv7RZ3qit>>Y299^;e#P*$M{m$k!JJr>EYO&FHMy*676XWicd00000!5y-< literal 0 HcmV?d00001 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