Files
ops-warden/tests/test_vault.py
tegwick f3547acd0b 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>
2026-05-15 17:05:38 +02:00

214 lines
6.8 KiB
Python

"""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