"""OpenBaoBackend tests -- OFFLINE against a fake OpenBao HTTP server. Mirror of test_cli_backend.py for the ``openbao`` backend. The fake (tests/fake_bao.py) records every request path: these tests assert that unlock + ref-index construction NEVER touch ``/data/*`` (the metadata API is the only index source -- zero plaintext transit), that items resolve to the same normalized shape as the bw backend, and that auth failures map to UNLOCK_FAILED / BROKER_LOCKED. """ from __future__ import annotations import json import os import sys import tempfile import threading import time import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parent)) import fake_bao from agent_credential_broker.config import Config from agent_credential_broker.core import BrokerCore from agent_credential_broker.errors import BrokerError from agent_credential_broker.server import _Server, call ROLE_ID = fake_bao.ROLE_ID SECRET_ID = fake_bao.SECRET_ID PW = fake_bao.PW TOKEN = fake_bao.TOKEN def _write(path: Path, text: str, mode=0o600): path.write_text(text) os.chmod(path, mode) def _cfg_dict(base: Path, port: int, **extra) -> dict: cfg = { "vault_backend": "openbao", "openbao_addr": f"http://127.0.0.1:{port}", "openbao_mount": "secret", "openbao_prefix": "agent", "unlock_ttl_seconds": 3600, "git_workdir_roots": [str(Path.home())], } cfg.update(extra) return cfg class BaoHarness(unittest.TestCase): """Daemon + socket + OpenBaoBackend against the fake server. AUTH_MODE selects the credential material provisioned in setUpClass: "approle" (role_id+secret_id files) or "token_file" (0600 token). """ AUTH_MODE = "approle" @classmethod def setUpClass(cls): # hermetic: a stray real token env var must not leak into fixtures os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None) cls.tmp = tempfile.TemporaryDirectory() base = Path(cls.tmp.name) os.environ["XDG_RUNTIME_DIR"] = str(base / "rt") os.environ["XDG_STATE_HOME"] = str(base / "state") (base / "rt").mkdir() cred = base / "cred" cred.mkdir() extra = {} if cls.AUTH_MODE == "approle": _write(cred / "role-id.txt", ROLE_ID) _write(cred / "secret-id.txt", SECRET_ID) extra["openbao_role_id_file"] = str(cred / "role-id.txt") extra["openbao_secret_id_file"] = str(cred / "secret-id.txt") extra["openbao_token_file"] = str(cred / "absent-token.txt") elif cls.AUTH_MODE == "token_file": _write(cred / "bao-token.txt", fake_bao.TOKEN_VALUE + "-seed") extra["openbao_token_file"] = str(cred / "bao-token.txt") extra["openbao_role_id_file"] = str(cred / "absent-role.txt") extra["openbao_secret_id_file"] = str(cred / "absent-secret.txt") # the seed token must be a live token on the fake server cls._seed_tokens = [fake_bao.TOKEN_VALUE + "-seed"] cls.bao, cls.bao_thread, cls.port = fake_bao.start_server() for tok in getattr(cls, "_seed_tokens", []): cls.bao.tokens.add(tok) cls.cfg_path = base / "broker-config.json" cls.cfg_path.write_text(json.dumps(_cfg_dict(base, cls.port, **extra))) os.environ["AGENT_BROKER_CONFIG"] = str(cls.cfg_path) os.environ["AGENT_BROKER_SOCKET"] = str( base / "rt" / "agent-broker" / "broker.sock") os.environ["AGENT_BROKER_PROFILES_DIR"] = str( Path(__file__).resolve().parents[1] / "profiles") cls.socket_path = Path(os.environ["AGENT_BROKER_SOCKET"]) cls.socket_path.parent.mkdir(parents=True, exist_ok=True) cls.core = BrokerCore(Config.load()) cls.server = _Server(str(cls.socket_path), cls.core) os.chmod(cls.socket_path, 0o600) threading.Thread(target=cls.server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True).start() @classmethod def tearDownClass(cls): cls.server.shutdown() cls.bao.shutdown() cls.tmp.cleanup() os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None) def call(self, method, params=None): return call(method, params or {}) def data_calls(self, since=0): return [p for m, p in self.bao.calls[since:] if f"/data/" in p] class TestApproleUnlock(BaoHarness): """unlock via AppRole: role_id+secret_id files -> auth/approle/login.""" def test_unlock_status_refs(self): st = self.call("status") self.assertTrue(st["locked"]) self.assertEqual(st["backend"], "openbao") res = self.call("unlock", {"password": "ignored-by-design"}) self.assertTrue(res["unlocked"]) self.assertEqual(res["items"], 4) # incl. nested + ref-less item refs = {r["ref"]: r for r in self.call("list_refs")} self.assertEqual( set(refs), {"gitea.admin", "test.service", "infra.router"}) blob = json.dumps(refs) self.assertNotIn(PW, blob) self.assertNotIn(TOKEN, blob) self.call("lock") def test_index_never_touches_data(self): """unlock + list_refs must use ONLY metadata endpoints.""" n0 = len(self.bao.calls) self.call("unlock", {"password": "x"}) self.call("list_refs") self.assertEqual(self.data_calls(since=n0), []) self.assertTrue( any("/metadata/agent" in p for m, p in self.bao.calls[n0:])) self.call("lock") def test_resolve_fetches_item_on_demand(self): n0 = len(self.bao.calls) self.call("unlock", {"password": "x"}) self.assertEqual(self.data_calls(since=n0), []) cred = self.call("resolve", {"ref": "gitea.admin", "url": "https://git2.paginica.com/login"}) self.assertEqual(cred["password"], PW) self.assertEqual(cred["username"], "gitea-admin") # exactly one data read, for the requested path only self.assertEqual(self.data_calls(since=n0), ["/v1/secret/data/agent/gitea.admin"]) self.call("lock") def test_nested_path_id(self): self.call("unlock", {"password": "x"}) meta = self.core.backend.get_item("infra/router") self.assertEqual(meta["id"], "infra/router") self.assertEqual(meta["username"], "root") self.assertEqual(meta["fields"]["agent.ref"], "infra.router") self.call("lock") def test_lock_revokes_token(self): self.call("unlock", {"password": "x"}) tok = self.core.backend._token self.call("lock") self.assertTrue(self.call("status")["locked"]) self.assertIn(("POST", "/v1/auth/token/revoke-self"), self.bao.calls) self.assertNotIn(tok, self.bao.tokens) # revoked server-side self.assertIsNone(self.core.backend._token) def test_logout_revokes(self): self.call("unlock", {"password": "x"}) self.call("logout") self.assertIn(("POST", "/v1/auth/token/revoke-self"), self.bao.calls) self.assertTrue(self.call("status")["locked"]) def test_wrong_secret_id_unlock_failed(self): self.call("lock") # clean slate in case a prior test left state # rotate the expected secret on the fake server: next login fails self.bao.secret_id = "other-secret" try: with self.assertRaises(BrokerError) as e: self.call("unlock", {"password": "x"}) self.assertEqual(e.exception.code, "UNLOCK_FAILED") finally: self.bao.secret_id = SECRET_ID self.assertTrue(self.call("status")["locked"]) def test_403_mid_session_relog_then_locked(self): """A 403 on data/* triggers ONE transparent approle re-login; a second 403 drops the token -> BROKER_LOCKED.""" n0 = len(self.bao.calls) self.call("unlock", {"password": "x"}) logins = sum(1 for m, p in self.bao.calls[n0:] if "approle/login" in p) self.assertEqual(logins, 1) self.bao.forbidden.add("agent/gitea.admin") try: with self.assertRaises(BrokerError) as e: self.call("resolve", {"ref": "gitea.admin", "url": "https://git2.paginica.com"}) self.assertEqual(e.exception.code, "BROKER_LOCKED") logins = sum(1 for m, p in self.bao.calls[n0:] if "approle/login" in p) self.assertEqual(logins, 2) # exactly one re-login attempt self.assertIsNone(self.core.backend._token) finally: self.bao.forbidden.discard("agent/gitea.admin") self.call("lock") class TestTokenFileUnlock(BaoHarness): AUTH_MODE = "token_file" SEED = fake_bao.TOKEN_VALUE + "-seed" def setUp(self): # lock() revokes the shared fixture token server-side; re-seed it # so each test starts from a live token again. self.bao.tokens.add(self.SEED) def test_unlock_via_token_file(self): res = self.call("unlock", {"password": "still-ignored"}) self.assertTrue(res["unlocked"]) # no approle login happened -- lookup-self verified the file token self.assertFalse(any("approle/login" in p for m, p in self.bao.calls)) self.assertIn(("GET", "/v1/auth/token/lookup-self"), self.bao.calls) self.call("lock") def test_403_locks_without_relogin(self): """No approle creds -> a 403 cannot re-login; straight to locked.""" self.call("unlock", {"password": "x"}) self.bao.forbidden.add("agent/test.service") try: with self.assertRaises(BrokerError) as e: self.core.backend.get_item("test.service") self.assertEqual(e.exception.code, "BROKER_LOCKED") self.assertFalse(any("approle/login" in p for m, p in self.bao.calls)) finally: self.bao.forbidden.discard("agent/test.service") self.call("lock") def test_renewable_token_renews_near_expiry(self): self.bao.lookup_ttl = 30 # < _RENEW_MARGIN_S -> renew on next call self.bao.lookup_renewable = True try: self.call("unlock", {"password": "x"}) # renewal already happened inside unlock's metadata prefetch self.assertIn(("POST", "/v1/auth/token/renew-self"), self.bao.calls) self.call("lock") finally: self.bao.lookup_ttl = 3600 class TestBackendUnit(unittest.TestCase): """Direct OpenBaoBackend use (no socket) for shape/error details.""" def setUp(self): os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None) self.tmp = tempfile.TemporaryDirectory() base = Path(self.tmp.name) os.environ["XDG_RUNTIME_DIR"] = str(base / "rt") os.environ["XDG_STATE_HOME"] = str(base / "state") (base / "rt").mkdir() cred = base / "cred" cred.mkdir() _write(cred / "role-id.txt", ROLE_ID) _write(cred / "secret-id.txt", SECRET_ID) self.bao, self._t, self.port = fake_bao.start_server() cfgd = _cfg_dict(base, self.port, openbao_role_id_file=str(cred / "role-id.txt"), openbao_secret_id_file=str(cred / "secret-id.txt"), openbao_token_file=str(cred / "absent-token.txt")) self.cfgd = cfgd from agent_credential_broker.backends.openbao import OpenBaoBackend self.backend = OpenBaoBackend(Config(cfgd)) def tearDown(self): self.bao.shutdown() self.tmp.cleanup() os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None) def test_get_item_full_shape(self): self.backend.unlock("ignored") item = self.backend.get_item("gitea.admin") self.assertEqual( set(item), {"id", "type", "name", "username", "password", "totp", "uris", "fields", "notes"}) self.assertEqual(item["id"], "gitea.admin") self.assertEqual(item["name"], "gitea admin") # from label self.assertEqual(item["username"], "gitea-admin") self.assertEqual(item["password"], PW) self.assertEqual(item["notes"], "bot account") self.assertEqual(item["uris"], ["https://git2.paginica.com"]) f = item["fields"] self.assertEqual(f["agent.ref"], "gitea.admin") self.assertEqual(f["agent.confirmation"], "AUTO") self.assertNotIn("label", f) # reserved metadata keys mapped away self.assertNotIn("uris", f) def test_get_item_csv_uris_and_no_label(self): self.backend.unlock("x") item = self.backend.get_item("test.service") self.assertEqual(item["name"], "api token") self.assertEqual(item["uris"], ["https://service.example", "https://alt.example"]) self.assertIsNone(item["username"]) self.backend.lock() def test_get_item_not_found(self): self.backend.unlock("x") with self.assertRaises(BrokerError) as e: self.backend.get_item("no-such-item") self.assertEqual(e.exception.code, "NOT_FOUND") def test_get_item_path_traversal_rejected(self): self.backend.unlock("x") for bad in ("../sys", "/sys", ".."): with self.assertRaises(BrokerError) as e: self.backend.get_item(bad) self.assertEqual(e.exception.code, "NOT_FOUND") def test_metadata_projection_agent_only(self): self.backend.unlock("x") meta = {m["id"]: m for m in self.backend.list_metadata()} g = meta["gitea.admin"] self.assertEqual(g["name"], "gitea admin") self.assertEqual(g["uris"], ["https://git2.paginica.com"]) self.assertTrue(all(k.startswith("agent.") for k in g["fields"])) for m in meta.values(): self.assertIsNone(m["password"]) self.assertIsNone(m["username"]) self.assertIsNone(m["notes"]) def test_empty_prefix_unlocks_with_zero_items(self): cfg = dict(self.cfgd) cfg["openbao_prefix"] = "empty-prefix" from agent_credential_broker.backends.openbao import OpenBaoBackend b = OpenBaoBackend(Config(cfg)) b.unlock("x") # LIST 404 -> empty index, not an error self.assertEqual(b.list_metadata(), []) def test_expired_token_locks(self): self.backend.unlock("x") self.backend._token_expires_at = time.time() - 1 with self.assertRaises(BrokerError) as e: self.backend.get_item("gitea.admin") self.assertEqual(e.exception.code, "BROKER_LOCKED") self.assertIsNone(self.backend._token) def test_unlock_via_token_env(self): tok = fake_bao.TOKEN_VALUE + "-env" self.bao.tokens.add(tok) os.environ["AGENT_BROKER_OPENBAO_TOKEN"] = tok cfg = dict(self.cfgd) cfg["openbao_role_id_file"] = str(Path(self.tmp.name) / "none") cfg["openbao_secret_id_file"] = str(Path(self.tmp.name) / "none2") cfg["openbao_token_file"] = str(Path(self.tmp.name) / "none3") from agent_credential_broker.backends.openbao import OpenBaoBackend b = OpenBaoBackend(Config(cfg)) self.assertTrue(b.configured()) b.unlock("x") self.assertEqual(b._token, tok) b.lock() def test_unlock_via_custom_token_env(self): tok = fake_bao.TOKEN_VALUE + "-custom" self.bao.tokens.add(tok) os.environ["MY_BAO_TOKEN"] = tok try: cfg = dict(self.cfgd) cfg["openbao_token_env"] = "MY_BAO_TOKEN" cfg["openbao_token_file"] = str(Path(self.tmp.name) / "none3") cfg["openbao_role_id_file"] = str(Path(self.tmp.name) / "none") cfg["openbao_secret_id_file"] = str(Path(self.tmp.name) / "n2") from agent_credential_broker.backends.openbao import OpenBaoBackend b = OpenBaoBackend(Config(cfg)) b.unlock("x") self.assertEqual(b._token, tok) b.lock() finally: os.environ.pop("MY_BAO_TOKEN", None) def test_unconfigured(self): cfg = dict(self.cfgd) for k in ("openbao_role_id_file", "openbao_secret_id_file", "openbao_token_file"): cfg[k] = str(Path(self.tmp.name) / ("absent-" + k)) from agent_credential_broker.backends.openbao import OpenBaoBackend b = OpenBaoBackend(Config(cfg)) self.assertFalse(b.configured()) with self.assertRaises(BrokerError) as e: b.unlock("x") self.assertEqual(e.exception.code, "UNCONFIGURED") def test_bad_perms_credential_file(self): cred = Path(self.tmp.name) / "cred" _write(cred / "secret-id.txt", SECRET_ID, mode=0o644) with self.assertRaises(BrokerError) as e: self.backend.unlock("x") self.assertEqual(e.exception.code, "UNCONFIGURED") os.chmod(cred / "secret-id.txt", 0o600) def test_server_down_backend_error(self): self.bao.shutdown() with self.assertRaises(BrokerError) as e: self.backend.unlock("x") self.assertEqual(e.exception.code, "VAULT_BACKEND_ERROR") self.assertNotIn(SECRET_ID, e.exception.message) def test_error_never_echoes_secret_id(self): self.bao.secret_id = "rotated" # force 400 on login try: with self.assertRaises(BrokerError) as e: self.backend.unlock("x") self.assertEqual(e.exception.code, "UNLOCK_FAILED") self.assertNotIn(SECRET_ID, e.exception.message) self.assertNotIn(ROLE_ID, e.exception.message) finally: self.bao.secret_id = SECRET_ID if __name__ == "__main__": unittest.main(verbosity=2)