generated from coulomb/repo-seed
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:
213
tests/test_vault.py
Normal file
213
tests/test_vault.py
Normal 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
|
||||
Reference in New Issue
Block a user