Initial commit
This commit is contained in:
+33
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export { KeePassDatabase, openKeePassDatabase } from "./keepass";
|
||||||
|
export type {
|
||||||
|
KeePassCommand,
|
||||||
|
KeePassEntry,
|
||||||
|
KeePassFindQuery,
|
||||||
|
KeePassGroup,
|
||||||
|
KeePassOpenOptions,
|
||||||
|
KeePassResponse,
|
||||||
|
} from "./types";
|
||||||
+110
@@ -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<KeePassEntry[]> {
|
||||||
|
const response = await this.run<KeePassEntry[]>({ command: "list-entries" });
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findEntries(query: KeePassFindQuery): Promise<KeePassEntry[]> {
|
||||||
|
const response = await this.run<KeePassEntry[]>({ command: "find-entries", query });
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGroups(): Promise<KeePassGroup[]> {
|
||||||
|
const response = await this.run<KeePassGroup[]>({ command: "list-groups" });
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
// No persistent process is kept alive yet.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async run<T>(command: KeePassCommand): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(output) as KeePassResponse<T>;
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<T> =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
+44
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"password": "123"
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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<boolean> {
|
||||||
|
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<FixtureEntry & { groupPath: string }> {
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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<string, ((chunk: Buffer | string) => 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user