Files
broker/tests/test_audit_query.py
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

305 lines
13 KiB
Python

import base64
import contextlib
import io
import json
import os
import sys
import tempfile
import time
import unittest
import urllib.error
import urllib.parse
import urllib.request
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"
AUDIT_PATH = "/api/broker/audit"
UUID_A = "0b6c3370-6d38-4b92-b9a2-bc2f1c9c1b0a"
UUID_B = "3f2d8a04-9c17-4b1e-8d2f-6a5b4c3d2e1f"
SEED = [
{"action": "read", "path": "coreci/proxmox", "user_id": "staff-1",
"app_registration_uuid": UUID_A, "agent_id": "proxmox"},
{"action": "read", "path": "coreci/gitea", "user_id": "staff-2",
"app_registration_uuid": UUID_A, "agent_id": "gitea"},
{"action": "denied", "path": "coreci/gitea", "result": "denied",
"user_id": "staff-2", "app_registration_uuid": UUID_B, "agent_id": "gitea"},
{"action": "add_app", "path": "/api/broker/app_registrations"},
{"action": "read", "path": "coreci/proxmox", "user_id": "staff-1",
"app_registration_uuid": UUID_B, "agent_id": "proxmox"},
{"action": "login", "path": "/api/broker/auth/login"},
{"action": "remove_user", "path": "/api/broker/user_allowlist/staff-2"},
]
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
class AuditQueryTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.audit_path = os.path.join(tmp.name, "broker.audit.log")
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot({
"BROKER_ENV_PATH": os.path.join(tmp.name, ".env"),
"BROKER_DB_PATH": os.path.join(tmp.name, "broker.db"),
"BROKER_AUDIT_LOG_PATH": self.audit_path,
"BROKER_ADMIN_USER": ADMIN_USER,
"BROKER_ADMIN_PASS": ADMIN_PASS,
})
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.auth = {"Authorization": _basic_header(ADMIN_USER, ADMIN_PASS)}
def _request_json(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=5) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read().decode("utf-8"))
def _get(self, params=None, headers=None):
path = AUDIT_PATH
if params:
path += "?" + urllib.parse.urlencode(params)
return self._request_json(
"GET", path, headers=self.auth if headers is None else headers
)
def _seed(self, specs):
for spec in specs:
self.broker.audit.write(**spec)
time.sleep(0.005)
def _ts(self):
values = []
with open(self.audit_path, "r", encoding="utf-8") as handle:
for raw in handle:
try:
values.append(json.loads(raw)["ts"])
except (ValueError, TypeError, KeyError):
continue
return values
def _paths(self, body):
return [entry["path"] for entry in body["entries"]]
def test_no_filters_returns_all_entries_with_null_cursor(self):
self._seed(SEED)
status, body = self._get()
self.assertEqual(200, status)
with open(self.audit_path, "r", encoding="utf-8") as handle:
expected = [json.loads(raw) for raw in handle]
self.assertEqual(expected, body["entries"])
self.assertEqual(len(SEED), len(body["entries"]))
self.assertIsNone(body["next_since"])
def test_user_id_filter_exact_match(self):
self._seed(SEED)
status, body = self._get({"user_id": "staff-1"})
self.assertEqual(200, status)
self.assertEqual(["coreci/proxmox", "coreci/proxmox"], self._paths(body))
self.assertEqual([UUID_A, UUID_B],
[e["app_registration_uuid"] for e in body["entries"]])
self.assertIsNone(body["next_since"])
def test_app_registration_uuid_filter_exact_match(self):
self._seed(SEED)
status, body = self._get({"app_registration_uuid": UUID_B})
self.assertEqual(200, status)
self.assertEqual(["denied", "read"], [e["action"] for e in body["entries"]])
self.assertEqual(["coreci/gitea", "coreci/proxmox"], self._paths(body))
status, body = self._get({"app_registration_uuid": "no-such-uuid"})
self.assertEqual(200, status)
self.assertEqual([], body["entries"])
self.assertIsNone(body["next_since"])
def test_action_filter_exact_match(self):
self._seed(SEED)
status, body = self._get({"action": "denied"})
self.assertEqual(200, status)
self.assertEqual(["coreci/gitea"], self._paths(body))
status, body = self._get({"action": "read"})
self.assertEqual(["coreci/proxmox", "coreci/gitea", "coreci/proxmox"],
self._paths(body))
status, body = self._get({"action": "rea"})
self.assertEqual(200, status)
self.assertEqual([], body["entries"])
def test_since_filter_is_exclusive(self):
self._seed(SEED)
ts = self._ts()
status, body = self._get({"since": ts[2]})
self.assertEqual(200, status)
self.assertEqual(["add_app", "read", "login", "remove_user"],
[e["action"] for e in body["entries"]])
self.assertEqual(4, len(body["entries"]))
self.assertIsNone(body["next_since"])
status, body = self._get({"since": ts[-1]})
self.assertEqual(200, status)
self.assertEqual([], body["entries"])
self.assertIsNone(body["next_since"])
def test_until_filter_is_inclusive(self):
self._seed(SEED)
ts = self._ts()
status, body = self._get({"until": ts[2]})
self.assertEqual(200, status)
self.assertEqual(["read", "read", "denied"],
[e["action"] for e in body["entries"]])
self.assertEqual(3, len(body["entries"]))
self.assertIsNone(body["next_since"])
def test_since_and_until_range_together(self):
self._seed(SEED)
ts = self._ts()
status, body = self._get({"since": ts[1], "until": ts[4]})
self.assertEqual(200, status)
self.assertEqual(["denied", "add_app", "read"], [e["action"] for e in body["entries"]])
status, body = self._get({"since": ts[3], "until": ts[3]})
self.assertEqual(200, status)
self.assertEqual([], body["entries"])
def test_combined_filters(self):
self._seed(SEED)
ts = self._ts()
status, body = self._get({"user_id": "staff-1", "action": "read"})
self.assertEqual(200, status)
self.assertEqual(["coreci/proxmox", "coreci/proxmox"], self._paths(body))
status, body = self._get({"user_id": "staff-2", "action": "read"})
self.assertEqual(["coreci/gitea"], self._paths(body))
status, body = self._get(
{"app_registration_uuid": UUID_B, "since": ts[2], "until": ts[6]}
)
self.assertEqual(["coreci/proxmox"], self._paths(body))
status, body = self._get(
{"user_id": "staff-1", "since": ts[0], "until": ts[4]}
)
self.assertEqual(["coreci/proxmox"], self._paths(body))
status, body = self._get(
{"user_id": "staff-1", "action": "denied",
"app_registration_uuid": UUID_A, "since": ts[0], "until": ts[6]}
)
self.assertEqual([], body["entries"])
def test_limit_pagination_with_next_since_cursor(self):
specs = [{"action": "read", "path": "coreci/agent-%d" % i} for i in range(5)]
self._seed(specs)
ts = self._ts()
status, body = self._get({"limit": 2})
self.assertEqual(200, status)
self.assertEqual(["coreci/agent-0", "coreci/agent-1"], self._paths(body))
self.assertEqual(ts[1], body["next_since"])
status, body = self._get({"since": body["next_since"], "limit": 2})
self.assertEqual(200, status)
self.assertEqual(["coreci/agent-2", "coreci/agent-3"], self._paths(body))
self.assertEqual(ts[3], body["next_since"])
status, body = self._get({"since": ts[1]})
self.assertEqual(200, status)
self.assertEqual(["coreci/agent-2", "coreci/agent-3", "coreci/agent-4"],
self._paths(body))
self.assertIsNone(body["next_since"])
status, body = self._get({"since": ts[4]})
self.assertEqual(200, status)
self.assertEqual([], body["entries"])
self.assertIsNone(body["next_since"])
def test_limit_zero_and_negative_clamp_to_one(self):
self._seed([{"action": "read", "path": "coreci/a"},
{"action": "read", "path": "coreci/b"},
{"action": "read", "path": "coreci/c"}])
ts = self._ts()
for bad in (0, -3):
status, body = self._get({"limit": bad})
self.assertEqual(200, status)
self.assertEqual(["coreci/a"], self._paths(body))
self.assertEqual(ts[0], body["next_since"])
def test_limit_above_max_and_non_numeric_are_clamped(self):
self._seed([{"action": "read", "path": "coreci/a"},
{"action": "read", "path": "coreci/b"},
{"action": "read", "path": "coreci/c"}])
for value in (5000, "abc"):
status, body = self._get({"limit": value})
self.assertEqual(200, status)
self.assertEqual(3, len(body["entries"]))
self.assertIsNone(body["next_since"])
def test_default_limit_is_exactly_100(self):
specs = [{"action": "read", "path": "coreci/agent-%d" % i} for i in range(100)]
self._seed(specs)
ts = self._ts()
status, body = self._get()
self.assertEqual(200, status)
self.assertEqual(100, len(body["entries"]))
self.assertEqual("coreci/agent-99", body["entries"][-1]["path"])
self.assertEqual(ts[99], body["next_since"])
status, body = self._get({"since": ts[99]})
self.assertEqual(200, status)
self.assertEqual([], body["entries"])
self.assertIsNone(body["next_since"])
def test_invalid_since_or_until_is_400(self):
self._seed(SEED)
status, body = self._get({"since": "not-a-timestamp"})
self.assertEqual(400, status)
self.assertEqual(400, body["error"])
self.assertTrue(body["message"])
status, body = self._get({"until": "2026-02-30T00:00:00Z"})
self.assertEqual(400, status)
self.assertEqual(400, body["error"])
self.assertTrue(body["message"])
status, body = self._get({"since": ""})
self.assertEqual(200, status)
self.assertEqual(len(SEED), len(body["entries"]))
def test_malformed_lines_skipped(self):
self._seed([{"action": "read", "path": "coreci/a"},
{"action": "read", "path": "coreci/b"}])
with open(self.audit_path, "a", encoding="utf-8") as handle:
handle.write("{not json\n")
handle.write("[1, 2, 3]\n")
self._seed([{"action": "read", "path": "coreci/c"}])
ts = self._ts()
self.assertEqual(3, len(ts))
status, body = self._get()
self.assertEqual(200, status)
self.assertEqual(["coreci/a", "coreci/b", "coreci/c"], self._paths(body))
self.assertIsNone(body["next_since"])
status, body = self._get({"limit": 2})
self.assertEqual(200, status)
self.assertEqual(["coreci/a", "coreci/b"], self._paths(body))
self.assertEqual(ts[1], body["next_since"])
status, body = self._get({"since": ts[1], "action": "read"})
self.assertEqual(200, status)
self.assertEqual(["coreci/c"], self._paths(body))
def test_unauthenticated_is_401(self):
self._seed(SEED)
status, body = self._get(headers={})
self.assertEqual(401, status)
self.assertEqual(401, body["error"])
status, body = self._get(
headers={"Authorization": _basic_header(ADMIN_USER, "wrong-pass")}
)
self.assertEqual(401, status)
self.assertEqual(401, body["error"])
if __name__ == "__main__":
unittest.main()