Skip to content

Commit f4f65bb

Browse files
chrisdpurcellclaude
andcommitted
test(keepass-cred-mgr): add security, integration, and unit tests (204 → 241)
Security tests: audit redaction, CLI notes injection, file shredding verification, XML special char escaping. Integration tests: get_entry, search, get/add attachment, import with password-auth merge adapter. Fixed missing pytestmark=pytest.mark.unit on 4 test files that caused pytest -m unit to silently drop 74 tests. Coverage: 80% → 99.8%. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7170f5f commit f4f65bb

11 files changed

Lines changed: 581 additions & 0 deletions

File tree

plugins/keepass-cred-mgr/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [0.5.3] - 2026-03-18
8+
9+
### Added
10+
- Security tests: audit log redaction (`_sanitize_extra`), CLI notes injection prevention (`_escape_notes_for_cli`), temp file shredding content verification, XML special character escaping in bulk import
11+
- Integration tests: `get_entry`, `search_entries`, `get_attachment`, `add_attachment`, and `import_entries` against real keepassxc-cli
12+
- Unit tests: REPL newline sanitization, stdin_lines embedded newline guard, diagnostics edge cases, `_configure_logging`, `list_entries` error handler
13+
- Testing section in README with run commands
14+
- `docs/testing/TEST_STATUS.json` for persistent test posture tracking
15+
16+
### Fixed
17+
- Four test files (`test_audit.py`, `test_config.py`, `test_main.py`, `test_yubikey.py`) were missing `pytestmark = pytest.mark.unit`, causing `pytest -m unit` to silently drop 74 tests and report 80% coverage instead of 99%
18+
719
## [0.5.2] - 2026-03-18
820

921
### Fixed

plugins/keepass-cred-mgr/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,24 @@ audit_log_path: ~/.local/share/keepass-cred-mgr/audit.jsonl
188188

189189
- **Two-database architecture**: A primary database (YubiKey-only, used by the MCP server) and a backup database (password-only, never used by the server) kept in sync via KeePassXC's merge function. If the YubiKey is lost, the backup provides recovery without compromising the primary's auth model.
190190

191+
## Testing
192+
193+
241 tests across unit, integration, and security categories at 99% line coverage.
194+
195+
```bash
196+
# Unit tests (no external dependencies)
197+
cd plugins/keepass-cred-mgr
198+
.venv/bin/python -m pytest tests/unit/ -m unit
199+
200+
# Integration tests (requires keepassxc-cli + test.kdbx fixture)
201+
.venv/bin/python -m pytest tests/integration/ -m integration
202+
203+
# Full suite with coverage
204+
.venv/bin/python -m pytest tests/ --cov=server --cov-report=term-missing
205+
```
206+
207+
Security-specific tests cover audit log redaction of sensitive keys, CLI notes injection prevention, temp file shredding verification, XML special character escaping, and tag-based access control enforcement (AI RESTRICTED and READ ONLY).
208+
191209
## Planned Features
192210

193211
- **Credential provisioning agent**: A purpose-built subagent for setting up new project environments; audits existing vault entries, generates missing credentials, and stores them with consistent naming.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
{
2+
"project": "keepass-cred-mgr",
3+
"last_analysis": "2026-03-18T20:10:00Z",
4+
"profile": "generic-python",
5+
"coverage": {
6+
"total_pct": 99,
7+
"tool": "pytest-cov",
8+
"by_file": {
9+
"server/audit.py": 100,
10+
"server/config.py": 100,
11+
"server/diagnostics.py": 100,
12+
"server/main.py": 99,
13+
"server/tools/read.py": 100,
14+
"server/tools/write.py": 100,
15+
"server/vault.py": 100,
16+
"server/yubikey.py": 100
17+
}
18+
},
19+
"categories": {
20+
"unit": {
21+
"applicable": true,
22+
"test_count": 230,
23+
"status": "pass",
24+
"files": [
25+
"tests/unit/test_audit.py",
26+
"tests/unit/test_config.py",
27+
"tests/unit/test_diagnostics.py",
28+
"tests/unit/test_main.py",
29+
"tests/unit/test_tools.py",
30+
"tests/unit/test_vault.py",
31+
"tests/unit/test_yubikey.py"
32+
]
33+
},
34+
"integration": {
35+
"applicable": true,
36+
"test_count": 11,
37+
"status": "pass",
38+
"notes": "Requires keepassxc-cli + test.kdbx fixture; skips cleanly when unavailable",
39+
"files": [
40+
"tests/integration/test_integration.py"
41+
]
42+
},
43+
"e2e": { "applicable": false },
44+
"contract": { "applicable": false },
45+
"security": {
46+
"applicable": true,
47+
"test_count": 27,
48+
"status": "pass",
49+
"notes": "Tag enforcement (AI RESTRICTED, READ ONLY), audit redaction (_sanitize_extra), notes CLI injection prevention, file shredding verification, XML special character escaping",
50+
"files": [
51+
"tests/unit/test_audit.py (TestSanitizeExtra)",
52+
"tests/unit/test_tools.py (TestAiRestrictedEnforcement, TestReadOnlyEnforcement, TestEscapeNotesForCli, TestShredFileContentOverwrite, TestBuildImportXmlEscaping)"
53+
]
54+
},
55+
"ui": { "applicable": false }
56+
},
57+
"known_gaps": [
58+
{
59+
"file": "server/main.py",
60+
"lines": "312, 316",
61+
"category": "unit",
62+
"priority": "low",
63+
"description": "main() and __main__ guard — entry point boilerplate",
64+
"reason_deferred": "Not unit-testable; exercised by running the server"
65+
}
66+
],
67+
"source_bugs_fixed": [],
68+
"history": [
69+
{
70+
"date": "2026-03-18",
71+
"action": "convergence_loop",
72+
"summary": "Gap analysis + convergence: added 7 tests (3 vault newline guards, 2 diagnostics edge cases, 2 stdin_lines guards). Fixed missing pytestmark=pytest.mark.unit on 4 test files. Coverage: 80% → 99% (with -m unit filter). 206 tests, all passing. 3 low-priority gaps deferred (main.py entry-point boilerplate)."
73+
},
74+
{
75+
"date": "2026-03-18",
76+
"action": "convergence_loop",
77+
"summary": "Full multi-category gap analysis. Added 35 tests: 9 security (audit redaction, notes escaping, shred verification, XML escaping), 4 unit (configure_logging, list_entries error, main callable), 6 integration (get_entry, search, get/add attachment, import with password-auth merge adapter), 2 diagnostics. Coverage: 99% → 99.8%. Total: 241 tests (230 unit + 11 integration), all passing. 1 gap deferred (main.py entry point)."
78+
}
79+
]
80+
}

plugins/keepass-cred-mgr/tests/integration/test_integration.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,136 @@ async def test_any_group_accessible(self, integration_setup):
142142
result = await list_entries(vault, group="Servers")
143143
titles = [e["title"] for e in result]
144144
assert any("Server" in t or "DB" in t for t in titles)
145+
146+
147+
class TestIntegrationGetEntry:
148+
async def test_get_entry_returns_password(self, integration_setup):
149+
"""get_entry returns full record including password via real keepassxc-cli."""
150+
from server.tools.read import get_entry
151+
152+
vault, audit, config, db = integration_setup
153+
# test.kdbx has pre-seeded entries in Servers group
154+
groups = await vault.run_cli("ls", str(db))
155+
group_names = [line.rstrip("/") for line in groups.strip().splitlines() if line.endswith("/")]
156+
assert len(group_names) > 0
157+
158+
# List entries in first group and get one
159+
from server.tools.read import list_entries
160+
entries = await list_entries(vault, group=group_names[0])
161+
if entries:
162+
entry = await get_entry(vault, audit, title=entries[0]["title"], group=group_names[0])
163+
assert "title" in entry
164+
assert "password" in entry
165+
assert "username" in entry
166+
167+
168+
class TestIntegrationSearch:
169+
async def test_search_entries_finds_existing(self, integration_setup):
170+
"""search_entries finds entries by keyword via real keepassxc-cli."""
171+
from server.tools.read import search_entries
172+
173+
vault, audit, config, db = integration_setup
174+
# "Server" should match entries in the Servers group
175+
results = await search_entries(vault, query="Server")
176+
assert len(results) > 0
177+
assert any("Server" in r.get("title", "") or "server" in r.get("title", "").lower()
178+
for r in results)
179+
180+
181+
class TestIntegrationGetAttachment:
182+
async def test_get_attachment_binary(self, integration_setup):
183+
"""get_attachment returns binary data for an existing attachment."""
184+
from server.tools.read import get_attachment, list_entries
185+
from server.vault import KeePassCLIError
186+
187+
vault, audit, config, db = integration_setup
188+
# Create an entry with an attachment first, then retrieve it
189+
from server.tools.write import create_entry, add_attachment
190+
191+
await create_entry(vault, audit, title="Attach Test", group="SSH Keys", username="u")
192+
test_content = b"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test@host"
193+
await add_attachment(
194+
vault, audit, title="Attach Test", attachment_name="test_key.pub",
195+
content=test_content, group="SSH Keys",
196+
)
197+
result = await get_attachment(
198+
vault, audit, title="Attach Test",
199+
attachment_name="test_key.pub", group="SSH Keys",
200+
)
201+
assert result == test_content
202+
203+
204+
class TestIntegrationAddAttachment:
205+
async def test_add_attachment_roundtrip(self, integration_setup):
206+
"""add_attachment stores a file that can be retrieved."""
207+
from server.tools.write import add_attachment, create_entry
208+
from server.tools.read import get_attachment
209+
210+
vault, audit, config, db = integration_setup
211+
await create_entry(vault, audit, title="File Store", group="API Keys", username="u")
212+
content = b"api_key=abc123\nsecret=xyz789"
213+
await add_attachment(
214+
vault, audit, title="File Store", attachment_name="creds.env",
215+
content=content, group="API Keys",
216+
)
217+
result = await get_attachment(
218+
vault, audit, title="File Store",
219+
attachment_name="creds.env", group="API Keys",
220+
)
221+
assert result == content
222+
223+
224+
class TestIntegrationImport:
225+
async def test_import_creates_entries(self, integration_setup):
226+
"""import_entries creates entries that appear in list_entries after re-unlock.
227+
228+
import_entries runs keepassxc-cli merge as a direct subprocess with
229+
--yubikey auth. This test patches subprocess.run to adapt the merge
230+
command for password auth (matching the PasswordVault test database).
231+
"""
232+
import subprocess as _subprocess
233+
from unittest.mock import patch as _patch
234+
235+
from server.tools.read import list_entries
236+
from server.tools.write import import_entries
237+
238+
vault, audit, config, db = integration_setup
239+
db_password = "testpassword"
240+
real_run = _subprocess.run
241+
242+
def merge_with_password(cmd, *args, **kwargs):
243+
"""Adapt keepassxc-cli merge from YubiKey to password auth."""
244+
if isinstance(cmd, list) and len(cmd) > 1 and cmd[1] == "merge":
245+
new_cmd = []
246+
skip_next = False
247+
for c in cmd:
248+
if skip_next:
249+
skip_next = False
250+
continue
251+
if c == "--yubikey":
252+
skip_next = True
253+
continue
254+
if c == "--no-password":
255+
continue
256+
new_cmd.append(c)
257+
existing_input = kwargs.get("input", "")
258+
kwargs["input"] = db_password + "\n" + existing_input
259+
return real_run(new_cmd, *args, **kwargs)
260+
return real_run(cmd, *args, **kwargs)
261+
262+
with _patch("subprocess.run", side_effect=merge_with_password):
263+
result = await import_entries(vault, audit, entries=[
264+
{"group": "Servers", "title": "Imported-1", "username": "import_user"},
265+
{"group": "Servers", "title": "Imported-2", "username": "import_user2"},
266+
])
267+
assert "Imported 2" in result
268+
269+
# Vault is locked after import — re-unlock and verify entries exist.
270+
# keepassxc-cli merge creates entries under a parallel group (UUID
271+
# mismatch), so search vault-wide instead of listing a specific group.
272+
await vault.unlock()
273+
from server.tools.read import search_entries
274+
found = await search_entries(vault, query="Imported")
275+
found_titles = [e["title"] for e in found]
276+
assert "Imported-1" in found_titles
277+
assert "Imported-2" in found_titles

plugins/keepass-cred-mgr/tests/unit/test_audit.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
import pytest
55

6+
pytestmark = pytest.mark.unit
7+
68

79
class TestAuditLogger:
810
def test_log_creates_file(self, test_config):
@@ -116,3 +118,69 @@ def test_permission_error_logs_warning(self, tmp_path, caplog):
116118
logger.log(tool="test", title="Test")
117119
finally:
118120
os.chmod(audit_path, 0o644)
121+
122+
123+
class TestSanitizeExtra:
124+
"""_sanitize_extra redacts values for keys containing sensitive fragments.
125+
126+
This is a security-critical function: a regression silently leaks
127+
passwords, tokens, or API keys to the audit log file on disk.
128+
"""
129+
130+
def test_password_key_redacted(self):
131+
from server.audit import _sanitize_extra
132+
result = _sanitize_extra({"database_password": "s3cret"})
133+
assert result["database_password"] == "**REDACTED**"
134+
135+
def test_token_key_redacted(self):
136+
from server.audit import _sanitize_extra
137+
result = _sanitize_extra({"refresh_token": "abc123"})
138+
assert result["refresh_token"] == "**REDACTED**"
139+
140+
def test_api_key_redacted(self):
141+
from server.audit import _sanitize_extra
142+
result = _sanitize_extra({"api_key": "key-xyz"})
143+
assert result["api_key"] == "**REDACTED**"
144+
145+
def test_auth_key_redacted(self):
146+
from server.audit import _sanitize_extra
147+
result = _sanitize_extra({"auth_header": "Bearer xyz"})
148+
assert result["auth_header"] == "**REDACTED**"
149+
150+
def test_case_insensitive_matching(self):
151+
from server.audit import _sanitize_extra
152+
result = _sanitize_extra({"API_KEY": "val", "Secret_Value": "val2"})
153+
assert result["API_KEY"] == "**REDACTED**"
154+
assert result["Secret_Value"] == "**REDACTED**"
155+
156+
def test_non_sensitive_key_passes_through(self):
157+
from server.audit import _sanitize_extra
158+
result = _sanitize_extra({"group": "Servers", "title": "Test"})
159+
assert result["group"] == "Servers"
160+
assert result["title"] == "Test"
161+
162+
def test_mixed_sensitive_and_safe(self):
163+
from server.audit import _sanitize_extra
164+
result = _sanitize_extra({
165+
"title": "My Entry",
166+
"password_hash": "abc",
167+
"count": 5,
168+
})
169+
assert result["title"] == "My Entry"
170+
assert result["password_hash"] == "**REDACTED**"
171+
assert result["count"] == 5
172+
173+
def test_empty_dict_returns_empty(self):
174+
from server.audit import _sanitize_extra
175+
assert _sanitize_extra({}) == {}
176+
177+
def test_redaction_used_in_audit_log(self, tmp_path):
178+
"""End-to-end: extra kwargs with sensitive keys are redacted in the log file."""
179+
import json
180+
from server.audit import AuditLogger
181+
182+
audit_path = tmp_path / "audit.jsonl"
183+
logger = AuditLogger(str(audit_path))
184+
logger.log(tool="test", title="Test", credential="should-be-redacted")
185+
record = json.loads(audit_path.read_text().strip())
186+
assert record["credential"] == "**REDACTED**"

plugins/keepass-cred-mgr/tests/unit/test_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import pytest
55
import yaml
66

7+
pytestmark = pytest.mark.unit
8+
79

810
@pytest.fixture
911
def valid_config(tmp_path):

plugins/keepass-cred-mgr/tests/unit/test_diagnostics.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,29 @@ def test_hidraw_not_readable_treated_as_missing(self, mock_run, mock_glob, mock_
128128
]
129129
result = diagnose_unlock_failure(_make_config())
130130
assert "hidraw" in result.lower() or "unbind" in result.lower()
131+
132+
@patch("os.access", return_value=True)
133+
@patch("glob.glob", return_value=["/dev/hidraw0"])
134+
@patch("subprocess.run")
135+
def test_udevadm_nonzero_returncode_skips_device(self, mock_run, mock_glob, mock_access):
136+
"""When udevadm returns non-zero for a hidraw device, the loop continues past it."""
137+
from server.diagnostics import diagnose_unlock_failure
138+
139+
mock_run.side_effect = [
140+
MagicMock(returncode=3), # pcscd inactive
141+
MagicMock(returncode=1, stdout=""), # udevadm fails for hidraw0
142+
]
143+
# No YubiKey OTP interface found → falls through to USB reset message
144+
result = diagnose_unlock_failure(_make_config())
145+
assert "hidraw" in result.lower() or "unbind" in result.lower()
146+
147+
def test_check_raising_generic_exception_falls_through(self):
148+
"""A diagnostic check raising a non-subprocess Exception is caught and skipped."""
149+
from server.diagnostics import diagnose_unlock_failure
150+
151+
with patch("server.diagnostics._check_pcscd", side_effect=RuntimeError("unexpected")), \
152+
patch("server.diagnostics._check_hidraw", return_value=None), \
153+
patch("server.diagnostics._check_serial", return_value=None):
154+
result = diagnose_unlock_failure(_make_config())
155+
# All checks either raised or returned None → empty string
156+
assert result == ""

0 commit comments

Comments
 (0)