feat(warden): WARDEN-WP-0003 — test coverage, permissions, status --state-dir

- File permissions: os.chmod(cert, 0o600) after every sign in LocalCA and
  VaultCA; chmod(privkey, 0o600) and chmod(pubkey, 0o644) after generate_keypair
- Scorecard: add check_file_permissions() that flags world/group-readable
  cert and key files; run_scorecard now returns 6 checks
- warden status --state-dir: bypasses config loading entirely for operators
  who have a cert but no warden.yaml installed
- tests/test_vault.py: 11 VaultCA unit tests covering success, HTTP 403,
  RequestError, missing token, missing role, missing pubkey, TTL enforcement,
  eviction, signatures log, and cert mode 600
- tests/test_ca.py: generate_keypair tests (paths, args, overwrite, error,
  permissions) and cert mode 600 assertion after sign
- tests/test_scorecard.py: file_permissions check tests (pass, fail cert,
  fail keys dir); scorecard count updated to 6
- tests/test_cli.py: covers sign, issue, status, scorecard, inventory, log,
  cleanup commands using CliRunner and tmp config/inventory files
- tests/test_integration.py: @pytest.mark.integration tests against real
  ssh-keygen; excluded from default suite via pyproject addopts
- pyproject.toml: addopts = "-m 'not integration'", integration marker declared

All 100 unit tests pass; 3 integration tests pass; ruff clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 17:05:38 +02:00
parent cd1e385bc1
commit f3547acd0b
11 changed files with 974 additions and 55 deletions

View File

@@ -335,6 +335,28 @@ def test_local_ca_sign_evicts_existing_cert(tmp_path):
assert len(list(state.glob("agt-state-hub-bridge-cert.pub"))) == 1
def test_local_ca_sign_cert_mode_600(tmp_path):
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake-ca")
pubkey = tmp_path / "key.pub"
pubkey.write_text("ssh-ed25519 AAAA actor-key")
state = tmp_path / "state"
spec = CertSpec(
actor_name="agt-state-hub-bridge",
actor_type=ActorType.AGT,
pubkey_path=pubkey,
ttl_hours=24,
principals=["agt-task-bridge"],
identity="agt-state-hub-bridge",
)
with patch("warden.ca.subprocess.run", side_effect=_mock_run_factory(CERT_CONTENT)):
ca = LocalCA(ca_key, state)
record = ca.sign(spec)
assert oct(record.cert_path.stat().st_mode & 0o777) == oct(0o600)
def test_local_ca_sign_writes_signature_log(tmp_path):
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake-ca")
@@ -360,3 +382,102 @@ def test_local_ca_sign_writes_signature_log(tmp_path):
assert entry["actor"] == "agt-state-hub-bridge"
assert entry["backend"] == "local"
assert entry["ttl_hours"] == 24
# ---------------------------------------------------------------------------
# LocalCA.generate_keypair
# ---------------------------------------------------------------------------
def _mock_keygen_gen(cmd, **kwargs):
"""Mock for generate_keypair: writes privkey and pubkey based on -f arg."""
result = MagicMock()
result.returncode = 0
result.stdout = ""
result.stderr = ""
if "-f" in cmd:
idx = cmd.index("-f")
privkey = Path(cmd[idx + 1])
privkey.parent.mkdir(parents=True, exist_ok=True)
privkey.write_text("fake private key")
(privkey.parent / (privkey.name + ".pub")).write_text("ssh-ed25519 AAAA pubkey")
return result
def test_generate_keypair_returns_paths(tmp_path):
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake-ca")
ca = LocalCA(ca_key, tmp_path / "state")
with patch("warden.ca.subprocess.run", side_effect=_mock_keygen_gen):
privkey, pubkey = ca.generate_keypair("agt-test")
assert privkey.name == "agt-test_ed25519"
assert pubkey.name == "agt-test_ed25519.pub"
assert str(privkey).endswith("state/keys/agt-test_ed25519")
def test_generate_keypair_ed25519_no_passphrase(tmp_path):
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake-ca")
ca = LocalCA(ca_key, tmp_path / "state")
calls = []
def capturing_mock(cmd, **kwargs):
calls.append(cmd)
return _mock_keygen_gen(cmd, **kwargs)
with patch("warden.ca.subprocess.run", side_effect=capturing_mock):
ca.generate_keypair("agt-test")
assert len(calls) == 1
cmd = calls[0]
assert "-t" in cmd and cmd[cmd.index("-t") + 1] == "ed25519"
assert "-N" in cmd and cmd[cmd.index("-N") + 1] == ""
assert "-C" in cmd and cmd[cmd.index("-C") + 1] == "agt-test"
def test_generate_keypair_overwrites_existing(tmp_path):
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake-ca")
state = tmp_path / "state"
keys_dir = state / "keys"
keys_dir.mkdir(parents=True)
old_priv = keys_dir / "agt-test_ed25519"
old_pub = keys_dir / "agt-test_ed25519.pub"
old_priv.write_text("old key")
old_pub.write_text("old pubkey")
ca = LocalCA(ca_key, state)
with patch("warden.ca.subprocess.run", side_effect=_mock_keygen_gen):
privkey, pubkey = ca.generate_keypair("agt-test")
assert privkey.read_text() == "fake private key"
def test_generate_keypair_ca_error_on_failure(tmp_path):
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake-ca")
ca = LocalCA(ca_key, tmp_path / "state")
def fail_run(cmd, **kwargs):
result = MagicMock()
result.returncode = 1
result.stderr = "failed to generate key"
return result
with patch("warden.ca.subprocess.run", side_effect=fail_run):
with pytest.raises(CAError, match="Key generation failed"):
ca.generate_keypair("agt-test")
def test_generate_keypair_sets_permissions(tmp_path):
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake-ca")
ca = LocalCA(ca_key, tmp_path / "state")
with patch("warden.ca.subprocess.run", side_effect=_mock_keygen_gen):
privkey, pubkey = ca.generate_keypair("agt-test")
assert oct(privkey.stat().st_mode & 0o777) == oct(0o600)
assert oct(pubkey.stat().st_mode & 0o777) == oct(0o644)

395
tests/test_cli.py Normal file
View File

@@ -0,0 +1,395 @@
"""Tests for warden CLI commands."""
import json
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import patch
from typer.testing import CliRunner
from warden.cli import app
from warden.models import CertRecord
runner = CliRunner()
CERT_CONTENT = "ssh-ed25519-cert-v01@openssh.com AAAA_fake_cert_data"
def _write_config(tmp_path: Path, ca_key: Path, state_dir: Path, inventory_path: Path) -> Path:
cfg_path = tmp_path / "warden.yaml"
cfg_path.write_text(
f"backend: local\n"
f"ca_key: {ca_key}\n"
f"state_dir: {state_dir}\n"
f"inventory_path: {inventory_path}\n"
)
return cfg_path
def _write_inventory(path: Path, actors: list[dict]) -> None:
import yaml
path.parent.mkdir(parents=True, exist_ok=True)
actors_dict = {a["name"]: {k: v for k, v in a.items() if k != "name"} for a in actors}
path.write_text(yaml.dump({"actors": actors_dict}))
def _make_cert_record(tmp_path: Path, actor_name: str = "agt-test") -> CertRecord:
cert_path = tmp_path / f"{actor_name}-cert.pub"
cert_path.write_text(CERT_CONTENT)
return CertRecord(
identity=actor_name,
valid_before=datetime(2030, 1, 1, tzinfo=timezone.utc),
cert_path=cert_path,
signed_at=datetime(2026, 3, 28, 10, 0, 0, tzinfo=timezone.utc),
principals=["agt-task"],
actor_name=actor_name,
)
# ---------------------------------------------------------------------------
# warden sign
# ---------------------------------------------------------------------------
def test_sign_exits_0_outputs_cert(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
state_dir.mkdir()
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
pubkey = tmp_path / "key.pub"
pubkey.write_text("ssh-ed25519 AAAA")
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [{"name": "agt-test", "type": "agt", "principals": ["agt-task"], "ttl_hours": 24}])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
record = _make_cert_record(state_dir)
with patch("warden.cli.LocalCA") as MockCA:
MockCA.return_value.sign.return_value = record
result = runner.invoke(app, ["sign", "agt-test", "--pubkey", str(pubkey)])
assert result.exit_code == 0
assert CERT_CONTENT in result.output
def test_sign_exits_1_unknown_actor(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
pubkey = tmp_path / "key.pub"
pubkey.write_text("ssh-ed25519 AAAA")
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
result = runner.invoke(app, ["sign", "agt-unknown", "--pubkey", str(pubkey)])
assert result.exit_code == 1
def test_sign_exits_1_on_config_error(tmp_path, monkeypatch):
monkeypatch.setenv("WARDEN_CONFIG", str(tmp_path / "nonexistent.yaml"))
result = runner.invoke(app, ["sign", "agt-test", "--pubkey", "/tmp/k.pub"])
assert result.exit_code == 1
# ---------------------------------------------------------------------------
# warden issue
# ---------------------------------------------------------------------------
def test_issue_exits_1_on_vault_backend(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [])
cfg_path = tmp_path / "warden.yaml"
cfg_path.write_text(
f"backend: vault\n"
f"state_dir: {state_dir}\n"
f"inventory_path: {inv_path}\n"
f"vault:\n addr: http://127.0.0.1:8200\n"
)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg_path))
result = runner.invoke(app, ["issue", "agt-test"])
assert result.exit_code == 1
assert "local" in result.output
def test_issue_exits_0_local_backend(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
state_dir.mkdir()
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [{"name": "agt-test", "type": "agt", "principals": ["agt-task"], "ttl_hours": 24}])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
pubkey = state_dir / "keys" / "agt-test_ed25519.pub"
record = _make_cert_record(state_dir)
with patch("warden.cli.LocalCA") as MockCA:
MockCA.return_value.generate_keypair.return_value = (
state_dir / "keys" / "agt-test_ed25519",
pubkey,
)
MockCA.return_value.sign.return_value = record
result = runner.invoke(app, ["issue", "agt-test"])
assert result.exit_code == 0
assert "agt-test" in result.output
# ---------------------------------------------------------------------------
# warden status
# ---------------------------------------------------------------------------
def test_status_no_certs_empty_state_dir(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
state_dir.mkdir()
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
result = runner.invoke(app, ["status"])
assert result.exit_code == 0
assert "No certificates" in result.output
def test_status_state_dir_override_no_config(tmp_path):
"""--state-dir bypasses config loading entirely."""
state_dir = tmp_path / "state"
state_dir.mkdir()
result = runner.invoke(app, ["status", "--state-dir", str(state_dir)])
assert result.exit_code == 0
assert "No certificates" in result.output
def test_status_exits_1_expired_cert(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
state_dir.mkdir()
cert = state_dir / "agt-test-cert.pub"
cert.write_text(CERT_CONTENT)
meta = {
"identity": "agt-test",
"valid_before": datetime(2020, 1, 1, tzinfo=timezone.utc),
"principals": ["agt-task"],
}
with patch("warden.cli.parse_cert_metadata", return_value=meta):
result = runner.invoke(app, ["status", "--state-dir", str(state_dir)])
assert result.exit_code == 1
# ---------------------------------------------------------------------------
# warden scorecard
# ---------------------------------------------------------------------------
def test_scorecard_exits_0_clean(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
state_dir.mkdir()
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [{"name": "agt-bridge", "type": "agt", "principals": ["agt-task"], "ttl_hours": 24}])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
result = runner.invoke(app, ["scorecard"])
assert result.exit_code == 0
def test_scorecard_exits_1_on_fail(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
state_dir.mkdir()
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
inv_path = tmp_path / "inventory.yaml"
# bad-name has wrong prefix for AGT type
_write_inventory(inv_path, [{"name": "bad-name", "type": "agt", "principals": ["x"], "ttl_hours": 24}])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
result = runner.invoke(app, ["scorecard"])
assert result.exit_code == 1
# ---------------------------------------------------------------------------
# warden inventory add / list / remove
# ---------------------------------------------------------------------------
def test_inventory_round_trip(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
add_result = runner.invoke(app, ["inventory", "add", "agt-test", "--type", "agt"])
assert add_result.exit_code == 0
list_result = runner.invoke(app, ["inventory", "list"])
assert list_result.exit_code == 0
assert "agt-test" in list_result.output
remove_result = runner.invoke(app, ["inventory", "remove", "agt-test"])
assert remove_result.exit_code == 0
list_result2 = runner.invoke(app, ["inventory", "list"])
assert "agt-test" not in list_result2.output
def test_inventory_list_json(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [{"name": "agt-bridge", "type": "agt", "principals": ["agt-task"], "ttl_hours": 24}])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
result = runner.invoke(app, ["inventory", "list", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert "agt-bridge" in data
# ---------------------------------------------------------------------------
# warden log
# ---------------------------------------------------------------------------
def test_log_exits_0_no_log(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
state_dir.mkdir()
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
result = runner.invoke(app, ["log"])
assert result.exit_code == 0
def test_log_json_parseable(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
state_dir.mkdir()
log_path = state_dir / "signatures.log"
entry = {"timestamp": "2026-03-28T10:00:00+00:00", "actor": "agt-test",
"actor_type": "agt", "identity": "agt-test", "principals": ["agt-task"],
"ttl_hours": 24, "valid_before": "2026-03-29T10:00:00+00:00",
"cert_path": "/tmp/cert.pub", "backend": "local"}
log_path.write_text(json.dumps(entry) + "\n")
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
result = runner.invoke(app, ["log", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert isinstance(data, list)
assert data[0]["actor"] == "agt-test"
def test_log_filters_by_actor(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
state_dir.mkdir()
log_path = state_dir / "signatures.log"
for actor in ["agt-alpha", "agt-beta"]:
entry = {"timestamp": "2026-03-28T10:00:00+00:00", "actor": actor,
"actor_type": "agt", "identity": actor, "principals": [actor],
"ttl_hours": 24, "valid_before": "2026-03-29T10:00:00+00:00",
"cert_path": "/tmp/cert.pub", "backend": "local"}
log_path.open("a").write(json.dumps(entry) + "\n")
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
result = runner.invoke(app, ["log", "agt-alpha", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 1
assert data[0]["actor"] == "agt-alpha"
# ---------------------------------------------------------------------------
# warden cleanup
# ---------------------------------------------------------------------------
def test_cleanup_dry_run_no_delete(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
state_dir.mkdir()
cert = state_dir / "agt-test-cert.pub"
cert.write_text(CERT_CONTENT)
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
meta = {
"identity": "agt-test",
"valid_before": datetime(2020, 1, 1, tzinfo=timezone.utc),
"principals": ["agt-task"],
}
with patch("warden.cli.parse_cert_metadata", return_value=meta):
result = runner.invoke(app, ["cleanup", "--dry-run"])
assert result.exit_code == 0
assert cert.exists() # not deleted
assert "would remove" in result.output
def test_cleanup_removes_stale_cert(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
state_dir.mkdir()
cert = state_dir / "agt-test-cert.pub"
cert.write_text(CERT_CONTENT)
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
meta = {
"identity": "agt-test",
"valid_before": datetime(2020, 1, 1, tzinfo=timezone.utc),
"principals": ["agt-task"],
}
with patch("warden.cli.parse_cert_metadata", return_value=meta):
result = runner.invoke(app, ["cleanup"])
assert result.exit_code == 0
assert not cert.exists()
def test_cleanup_exits_0_nothing_to_clean(tmp_path, monkeypatch):
state_dir = tmp_path / "state"
state_dir.mkdir()
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake")
inv_path = tmp_path / "inventory.yaml"
_write_inventory(inv_path, [])
cfg = _write_config(tmp_path, ca_key, state_dir, inv_path)
monkeypatch.setenv("WARDEN_CONFIG", str(cfg))
result = runner.invoke(app, ["cleanup"])
assert result.exit_code == 0
assert "No stale" in result.output

104
tests/test_integration.py Normal file
View File

@@ -0,0 +1,104 @@
"""Integration tests: real ssh-keygen, no mocking.
Run with: uv run pytest -m integration
Excluded from the default unit suite by pyproject.toml addopts.
"""
import shutil
import subprocess
from datetime import datetime, timezone
from pathlib import Path
import pytest
from warden.ca import LocalCA, parse_cert_metadata
from warden.models import ActorType, CertSpec
pytestmark = pytest.mark.integration
@pytest.fixture(autouse=True)
def require_ssh_keygen():
if shutil.which("ssh-keygen") is None:
pytest.skip("ssh-keygen not found in PATH")
def _generate_ca_key(tmp_path: Path) -> Path:
ca_key = tmp_path / "ca_key"
subprocess.run(
["ssh-keygen", "-t", "ed25519", "-f", str(ca_key), "-N", "", "-C", "test-ca"],
check=True,
capture_output=True,
)
return ca_key
def _generate_actor_key(tmp_path: Path) -> Path:
key = tmp_path / "actor_key"
subprocess.run(
["ssh-keygen", "-t", "ed25519", "-f", str(key), "-N", "", "-C", "actor"],
check=True,
capture_output=True,
)
return key
def test_local_ca_sign_real_ssh_keygen(tmp_path):
ca_key = _generate_ca_key(tmp_path)
actor_key = _generate_actor_key(tmp_path)
pubkey = Path(str(actor_key) + ".pub")
spec = CertSpec(
actor_name="agt-integration-test",
actor_type=ActorType.AGT,
pubkey_path=pubkey,
ttl_hours=1,
principals=["agt-integration-test"],
identity="agt-integration-test",
)
state_dir = tmp_path / "state"
ca = LocalCA(ca_key, state_dir)
record = ca.sign(spec)
assert record.cert_path.exists()
assert record.valid_before > datetime.now(timezone.utc)
assert record.identity == "agt-integration-test"
assert record.principals == ["agt-integration-test"]
# Re-parse without any mocking
meta = parse_cert_metadata(record.cert_path)
assert meta["identity"] == "agt-integration-test"
assert meta["valid_before"] > datetime.now(timezone.utc)
def test_local_ca_sign_cert_file_mode_600(tmp_path):
ca_key = _generate_ca_key(tmp_path)
actor_key = _generate_actor_key(tmp_path)
pubkey = Path(str(actor_key) + ".pub")
spec = CertSpec(
actor_name="agt-integration-test",
actor_type=ActorType.AGT,
pubkey_path=pubkey,
ttl_hours=1,
principals=["agt-integration-test"],
identity="agt-integration-test",
)
ca = LocalCA(ca_key, tmp_path / "state")
record = ca.sign(spec)
assert oct(record.cert_path.stat().st_mode & 0o777) == oct(0o600)
def test_generate_keypair_real_ssh_keygen(tmp_path):
ca_key = _generate_ca_key(tmp_path)
ca = LocalCA(ca_key, tmp_path / "state")
privkey, pubkey = ca.generate_keypair("agt-integration-test")
assert privkey.exists()
assert pubkey.exists()
assert oct(privkey.stat().st_mode & 0o777) == oct(0o600)
assert oct(pubkey.stat().st_mode & 0o777) == oct(0o644)
assert "ssh-ed25519" in pubkey.read_text()

View File

@@ -10,6 +10,7 @@ from datetime import datetime, timezone
from warden.scorecard import (
check_actor_name_prefixes,
check_all_actors_have_principals,
check_file_permissions,
check_no_stale_certs,
check_no_expired_certs,
check_ttl_policy,
@@ -100,7 +101,7 @@ def test_run_scorecard_clean(tmp_path):
)
results = run_scorecard(tmp_path, inv)
assert all(r.passed for r in results)
assert len(results) == 5
assert len(results) == 6
# ---------------------------------------------------------------------------
@@ -160,6 +161,52 @@ def test_ttl_policy_skips_unknown_actor(tmp_path):
assert result.passed # unknown actor skipped, not a violation
# ---------------------------------------------------------------------------
# check_file_permissions
# ---------------------------------------------------------------------------
def test_file_permissions_no_state_dir():
result = check_file_permissions(Path("/nonexistent/state/dir"))
assert result.passed
def test_file_permissions_empty_dir(tmp_path):
result = check_file_permissions(tmp_path)
assert result.passed
def test_file_permissions_pass(tmp_path):
cert = tmp_path / "agt-bridge-cert.pub"
cert.write_text("fake")
cert.chmod(0o600)
result = check_file_permissions(tmp_path)
assert result.passed
def test_file_permissions_fail_world_readable(tmp_path):
cert = tmp_path / "agt-bridge-cert.pub"
cert.write_text("fake")
cert.chmod(0o644)
result = check_file_permissions(tmp_path)
assert not result.passed
assert "agt-bridge-cert.pub" in result.detail
def test_file_permissions_keys_dir(tmp_path):
keys_dir = tmp_path / "keys"
keys_dir.mkdir()
key = keys_dir / "agt-test_ed25519"
key.write_text("fake key")
key.chmod(0o644)
result = check_file_permissions(tmp_path)
assert not result.passed
assert "keys/agt-test_ed25519" in result.detail
# ---------------------------------------------------------------------------
# (continuation)
# ---------------------------------------------------------------------------
def test_stale_certs_detail_suggests_cleanup(tmp_path):
cert_path = tmp_path / "agt-bridge-cert.pub"
cert_path.write_text("fake")

213
tests/test_vault.py Normal file
View File

@@ -0,0 +1,213 @@
"""Tests for warden.vault — VaultCA backend."""
from unittest.mock import MagicMock, patch
import httpx
import pytest
from warden.ca import CAError
from warden.config import VaultConfig
from warden.models import ActorType, CertSpec
from warden.vault import VaultCA
SAMPLE_CERT = "ssh-ed25519-cert-v01@openssh.com AAAA_fake_cert_data"
SAMPLE_SSHKEYGEN_L = """\
/tmp/key-cert.pub:
Type: ssh-ed25519-cert-v01@openssh.com user certificate
Public key: ED25519-CERT SHA256:abc123
Signing CA: ED25519 SHA256:xyz (using ssh-ed25519)
Key ID: "agt-test"
Serial: 0
Valid: from 2026-03-28T10:00:00 to 2026-03-29T10:00:00
Principals:
agt-task
Critical Options: (none)
Extensions:
permit-pty
"""
def _make_cfg(**overrides):
defaults = {
"addr": "http://127.0.0.1:8200",
"mount": "ssh",
"token_env": "VAULT_TOKEN",
"role_map": {"agt": "agt-role", "adm": "adm-role", "atm": "atm-role"},
}
defaults.update(overrides)
return VaultConfig(**defaults)
def _make_spec(tmp_path, **overrides):
pubkey = tmp_path / "key.pub"
pubkey.write_text("ssh-ed25519 AAAA actor-key")
defaults = {
"actor_name": "agt-test",
"actor_type": ActorType.AGT,
"pubkey_path": pubkey,
"ttl_hours": 24,
"principals": ["agt-task"],
"identity": "agt-test",
}
defaults.update(overrides)
return CertSpec(**defaults)
def _mock_httpx_post(signed_key: str):
resp = MagicMock()
resp.json.return_value = {"data": {"signed_key": signed_key}}
resp.raise_for_status.return_value = None
return resp
def _mock_ssh_keygen_L(cmd, **kwargs):
result = MagicMock()
result.returncode = 0
result.stdout = SAMPLE_SSHKEYGEN_L
result.stderr = ""
return result
# ---------------------------------------------------------------------------
# VaultCA.sign — success
# ---------------------------------------------------------------------------
def test_vault_ca_sign_success(tmp_path, monkeypatch):
monkeypatch.setenv("VAULT_TOKEN", "fake-token")
spec = _make_spec(tmp_path)
cfg = _make_cfg()
ca = VaultCA(cfg, tmp_path / "state")
with (
patch("warden.vault.httpx.post", return_value=_mock_httpx_post(SAMPLE_CERT)),
patch("warden.ca.subprocess.run", side_effect=_mock_ssh_keygen_L),
):
record = ca.sign(spec)
assert record.actor_name == "agt-test"
assert record.identity == "agt-test"
assert record.principals == ["agt-task"]
dest = tmp_path / "state" / "agt-test-cert.pub"
assert dest.exists()
assert SAMPLE_CERT in dest.read_text()
def test_vault_ca_sign_cert_mode_600(tmp_path, monkeypatch):
monkeypatch.setenv("VAULT_TOKEN", "fake-token")
spec = _make_spec(tmp_path)
ca = VaultCA(_make_cfg(), tmp_path / "state")
with (
patch("warden.vault.httpx.post", return_value=_mock_httpx_post(SAMPLE_CERT)),
patch("warden.ca.subprocess.run", side_effect=_mock_ssh_keygen_L),
):
record = ca.sign(spec)
assert oct(record.cert_path.stat().st_mode & 0o777) == oct(0o600)
def test_vault_ca_sign_writes_signature_log(tmp_path, monkeypatch):
import json
monkeypatch.setenv("VAULT_TOKEN", "fake-token")
spec = _make_spec(tmp_path)
ca = VaultCA(_make_cfg(), tmp_path / "state")
with (
patch("warden.vault.httpx.post", return_value=_mock_httpx_post(SAMPLE_CERT)),
patch("warden.ca.subprocess.run", side_effect=_mock_ssh_keygen_L),
):
ca.sign(spec)
log_path = tmp_path / "state" / "signatures.log"
assert log_path.exists()
entry = json.loads(log_path.read_text().strip())
assert entry["backend"] == "vault"
assert entry["actor"] == "agt-test"
# ---------------------------------------------------------------------------
# VaultCA.sign — failure paths
# ---------------------------------------------------------------------------
def test_vault_ca_sign_http_403(tmp_path, monkeypatch):
monkeypatch.setenv("VAULT_TOKEN", "bad-token")
spec = _make_spec(tmp_path)
ca = VaultCA(_make_cfg(), tmp_path / "state")
request = httpx.Request("POST", "http://127.0.0.1:8200/v1/ssh/sign/agt-role")
response = httpx.Response(403, request=request, text="permission denied")
exc = httpx.HTTPStatusError("403", request=request, response=response)
with patch("warden.vault.httpx.post", side_effect=exc):
with pytest.raises(CAError, match="403"):
ca.sign(spec)
def test_vault_ca_sign_request_error(tmp_path, monkeypatch):
monkeypatch.setenv("VAULT_TOKEN", "fake-token")
spec = _make_spec(tmp_path)
ca = VaultCA(_make_cfg(), tmp_path / "state")
request = httpx.Request("POST", "http://127.0.0.1:8200/v1/ssh/sign/agt-role")
exc = httpx.ConnectError("connection refused", request=request)
with patch("warden.vault.httpx.post", side_effect=exc):
with pytest.raises(CAError, match="unreachable"):
ca.sign(spec)
def test_vault_ca_sign_missing_token(tmp_path, monkeypatch):
monkeypatch.delenv("VAULT_TOKEN", raising=False)
spec = _make_spec(tmp_path)
ca = VaultCA(_make_cfg(), tmp_path / "state")
with pytest.raises(CAError, match="VAULT_TOKEN"):
ca.sign(spec)
def test_vault_ca_sign_missing_role(tmp_path, monkeypatch):
monkeypatch.setenv("VAULT_TOKEN", "fake-token")
cfg = _make_cfg(role_map={}) # no roles mapped
spec = _make_spec(tmp_path)
ca = VaultCA(cfg, tmp_path / "state")
with pytest.raises(CAError, match="role_map"):
ca.sign(spec)
def test_vault_ca_sign_missing_pubkey(tmp_path, monkeypatch):
monkeypatch.setenv("VAULT_TOKEN", "fake-token")
spec = _make_spec(tmp_path, pubkey_path=tmp_path / "nonexistent.pub")
ca = VaultCA(_make_cfg(), tmp_path / "state")
with pytest.raises(CAError, match="Public key not found"):
ca.sign(spec)
def test_vault_ca_sign_ttl_enforcement(tmp_path, monkeypatch):
monkeypatch.setenv("VAULT_TOKEN", "fake-token")
spec = _make_spec(tmp_path, ttl_hours=100) # AGT max is 24h
ca = VaultCA(_make_cfg(), tmp_path / "state")
with pytest.raises(CAError, match="exceeds maximum"):
ca.sign(spec)
def test_vault_ca_sign_evicts_existing_cert(tmp_path, monkeypatch):
monkeypatch.setenv("VAULT_TOKEN", "fake-token")
state = tmp_path / "state"
state.mkdir()
old_cert = state / "agt-test-cert.pub"
old_cert.write_text("old cert")
spec = _make_spec(tmp_path)
ca = VaultCA(_make_cfg(), state)
with (
patch("warden.vault.httpx.post", return_value=_mock_httpx_post(SAMPLE_CERT)),
patch("warden.ca.subprocess.run", side_effect=_mock_ssh_keygen_L),
):
record = ca.sign(spec)
assert record.cert_path.read_text().strip() == SAMPLE_CERT
assert len(list(state.glob("agt-test-cert.pub"))) == 1