Files
broker/tests/test_secrets.py
T
coreci 7f7868f7cf docs(milestone): complete coreci-broker
---ci---
phase: 5
milestone: v0.1
status: complete
requirements:
  covered: [REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, REQ-08, REQ-09, REQ-10, REQ-11, REQ-12, REQ-13, REQ-14, REQ-15, REQ-16, REQ-17, REQ-18, REQ-19, REQ-20]
  partial: []
---/ci---
2026-10-10 15:30:14 +00:00

233 lines
10 KiB
Python

import base64
import contextlib
import io
import json
import os
import sys
import tempfile
import unittest
import urllib.error
import urllib.parse
import urllib.request
import uuid as uuidlib
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)))
import coreci_broker
ADMIN_USER = "admin"
ADMIN_PASS = "correct-horse-battery-staple"
REGISTER_PATH = "/api/broker/agents/register"
APPS_PATH = "/api/broker/app_registrations"
USERS_PATH = "/api/broker/user_allowlist"
AUDIT_PATH = "/api/broker/audit"
SECRETS_PREFIX = "/api/broker/secrets/"
AUTH_401_BODY = {"error": 401, "message": "invalid app credentials"}
PROXMOX_SECRET = {"url": "https://pve.coreci.dev:8006", "token": "pve-tok"}
GITEA_SECRET = {"url": "https://git.coreci.dev", "token": "git-tok"}
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
class _SecretsCase(unittest.TestCase):
creds_json = json.dumps(
{"coreci/proxmox": PROXMOX_SECRET, "coreci/gitea": GITEA_SECRET}
)
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.creds_path = os.path.join(self.tmp, "creds.json")
with open(self.creds_path, "w", encoding="utf-8") as handle:
handle.write(self.creds_json)
env = {
"BROKER_ENV_PATH": os.path.join(self.tmp, ".env"),
"BROKER_DB_PATH": os.path.join(self.tmp, "broker.db"),
"BROKER_AUDIT_LOG_PATH": os.path.join(self.tmp, "broker.audit.log"),
"BROKER_ADMIN_USER": ADMIN_USER,
"BROKER_ADMIN_PASS": ADMIN_PASS,
"BROKER_CREDS_SOURCE": "file",
"BROKER_CREDS_FILE": self.creds_path,
}
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot(env)
self.addCleanup(self.broker.audit.close)
self.addCleanup(self.broker.store.close)
self.server = coreci_broker.serve("127.0.0.1", 0, self.broker)
self.addCleanup(self.server.shutdown)
self.addCleanup(self.server.server_close)
self.base = "http://127.0.0.1:%d" % self.server.server_address[1]
self.admin = {"Authorization": _basic_header(ADMIN_USER, ADMIN_PASS)}
self.app_uuid = str(uuidlib.uuid4())
self.user_id = "ceo"
self._seed()
self.registered = self._register("proxmox", ["coreci"])
self.app = {"Authorization": "App %s %s" % (self.app_uuid, self.registered["token"])}
def _request(self, method, path, payload=None, headers=None):
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(self.base + path, data=data, method=method)
for key, value in (headers or {}).items():
request.add_header(key, value)
try:
with urllib.request.urlopen(request, timeout=10) as response:
raw = response.read().decode("utf-8")
return response.status, json.loads(raw) if raw else None
except urllib.error.HTTPError as error:
raw = error.read().decode("utf-8")
return error.code, json.loads(raw) if raw else None
def _seed(self):
status, _ = self._request("POST", APPS_PATH, {"uuid": self.app_uuid}, self.admin)
self.assertEqual(201, status)
status, _ = self._request("POST", USERS_PATH, {"user_id": self.user_id}, self.admin)
self.assertEqual(201, status)
def _register(self, agent_id, tags):
payload = {"agent_id": agent_id, "app_registration_uuid": self.app_uuid,
"user_id": self.user_id, "tags": list(tags)}
status, body = self._request("POST", REGISTER_PATH, payload)
self.assertEqual(201, status)
return body
def _secret(self, suffix, headers=None):
return self._request("GET", SECRETS_PREFIX + suffix,
headers=self.app if headers is None else headers)
def _audit(self, action):
query = urllib.parse.urlencode({"action": action, "limit": "1000"})
status, body = self._request("GET", AUDIT_PATH + "?" + query, headers=self.admin)
self.assertEqual(200, status)
return body["entries"]
def _read_entries(self, result):
return [entry for entry in self._audit("read") if entry["result"] == result]
def _assert_identity(self, entry, path, agent_id="proxmox"):
self.assertEqual("read", entry["action"])
self.assertEqual(path, entry["path"])
self.assertEqual(self.user_id, entry["user_id"])
self.assertEqual(self.app_uuid, entry["app_registration_uuid"])
self.assertEqual(agent_id, entry["agent_id"])
def _ghost_headers(self, agent_id):
ghost = self._register(agent_id, ["coreci"])
return {"Authorization": "App %s %s" % (self.app_uuid, ghost["token"])}
class SecretsReadTests(_SecretsCase):
def test_valid_read_returns_document_and_ok_audit(self):
status, body = self._secret("coreci/proxmox")
self.assertEqual(200, status)
self.assertEqual("coreci/proxmox", body["path"])
self.assertEqual(PROXMOX_SECRET, body["data"])
self.assertRegex(body["version"], r"^[0-9a-f]{8}$")
self.assertIn("updated_at", body)
entries = self._read_entries("ok")
self.assertEqual(1, len(entries))
self._assert_identity(entries[0], "coreci/proxmox")
def test_encoded_slash_path_resolves_to_same_secret(self):
status, body = self._secret("coreci%2Fproxmox")
self.assertEqual(200, status)
self.assertEqual("coreci/proxmox", body["path"])
self.assertEqual(PROXMOX_SECRET, body["data"])
self.assertEqual(1, len(self._read_entries("ok")))
def test_missing_app_header_is_401(self):
status, body = self._secret("coreci/proxmox", headers={})
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
self.assertEqual([], self._read_entries("ok"))
self.assertEqual([], self._read_entries("denied"))
def test_tampered_token_is_401_with_denied_audit(self):
token = self.registered["token"]
twisted = ("x" if token[0] != "x" else "y") + token[1:]
status, body = self._secret("coreci/proxmox", headers={
"Authorization": "App %s %s" % (self.app_uuid, twisted)})
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
entries = self._audit("denied")
self.assertEqual(1, len(entries))
self.assertEqual("denied", entries[0]["action"])
self.assertEqual("denied", entries[0]["result"])
self.assertEqual(self.app_uuid, entries[0]["app_registration_uuid"])
self.assertIsNone(entries[0]["user_id"])
self.assertIsNone(entries[0]["agent_id"])
def test_cross_path_read_is_403_with_denied_audit(self):
status, body = self._secret("coreci/gitea")
self.assertEqual(403, status)
self.assertEqual(403, body["error"])
self.assertTrue(body["message"])
entries = self._read_entries("denied")
self.assertEqual(1, len(entries))
self._assert_identity(entries[0], "coreci/gitea")
def test_out_of_scope_paths_are_403(self):
suffixes = ("coreci/proxmox/extra", "other/proxmox", "coreci", "CORECI/proxmox")
for suffix in suffixes:
with self.subTest(suffix=suffix):
status, body = self._secret(suffix)
self.assertEqual(403, status)
self.assertEqual(403, body["error"])
self.assertEqual(len(suffixes), len(self._read_entries("denied")))
def test_absent_secret_is_404_with_error_audit(self):
status, body = self._secret("coreci/nonexistent",
headers=self._ghost_headers("nonexistent"))
self.assertEqual(404, status)
self.assertEqual({"error": 404, "message": "not found"}, body)
entries = self._read_entries("error")
self.assertEqual(1, len(entries))
self._assert_identity(entries[0], "coreci/nonexistent", "nonexistent")
def test_traversal_payloads_never_reach_source(self):
original = self.broker.creds_source.get_secret
self.addCleanup(setattr, self.broker.creds_source, "get_secret", original)
def detonate(path):
raise AssertionError("creds source reached for %r" % path)
self.broker.creds_source.get_secret = detonate
attempts = ["%2e%2e/", "..%2f", "coreci%5cproxmox", "coreci%00proxmox",
"//coreci/proxmox", "%2e%2e/coreci/proxmox", "coreci/..%2fproxmox"]
for suffix in attempts:
with self.subTest(suffix=suffix):
status, body = self._secret(suffix)
self.assertEqual(403, status)
self.assertEqual(403, body["error"])
self.assertEqual(len(attempts), len(self._read_entries("denied")))
def test_audit_completeness_action_read_covers_all_results(self):
self._secret("coreci/proxmox")
self._secret("coreci/gitea")
self._secret("coreci/nonexistent", headers=self._ghost_headers("nonexistent"))
entries = self._audit("read")
self.assertEqual(3, len(entries))
by_result = {entry["result"]: entry for entry in entries}
self.assertEqual({"ok", "denied", "error"}, set(by_result))
self._assert_identity(by_result["ok"], "coreci/proxmox")
self._assert_identity(by_result["denied"], "coreci/gitea")
self._assert_identity(by_result["error"], "coreci/nonexistent", "nonexistent")
class SecretsSourceErrorTests(_SecretsCase):
creds_json = "{not-json"
def test_source_failure_is_500_contract_with_error_audit(self):
status, body = self._secret("coreci/proxmox")
self.assertEqual(500, status)
self.assertEqual({"error": 500, "message": "creds source unavailable"}, body)
entries = self._read_entries("error")
self.assertEqual(1, len(entries))
self._assert_identity(entries[0], "coreci/proxmox")
if __name__ == "__main__":
unittest.main()