7f7868f7cf
---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---
345 lines
16 KiB
Python
345 lines
16 KiB
Python
import ast
|
|
import base64
|
|
import contextlib
|
|
import datetime
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
import urllib.error
|
|
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"
|
|
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
|
BROKER_SOURCE_PATH = os.path.join(REPO_ROOT, "coreci_broker.py")
|
|
CLIENT_SOURCE_PATH = os.path.join(REPO_ROOT, "coreci_broker_client.py")
|
|
CLIENT_STATIC_PATH = "/static/coreci_broker_client.py"
|
|
LOGIN_PATH = "/api/broker/auth/login"
|
|
LOGOUT_PATH = "/api/broker/auth/logout"
|
|
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/"
|
|
PROXMOX_PATH = "coreci/proxmox"
|
|
GITEA_PATH = "coreci/gitea"
|
|
PROXMOX_SECRET = {"url": "https://pve.coreci.dev:8006", "token": "pve-tok"}
|
|
GITEA_SECRET = {"url": "https://git.coreci.dev", "token": "git-tok"}
|
|
|
|
AC_PIN_IDS = {
|
|
"AC1": "test_acceptance_p2.AcceptanceP2Tests.test_ac1_first_boot_printed_pass_login_via_ui_login_page",
|
|
"AC2": "test_acceptance_p2.AcceptanceP2Tests.test_ac2_app_registration_lifecycle_via_session_cookie",
|
|
"AC3": "test_acceptance_p2.AcceptanceP2Tests.test_ac3_user_allowlist_lifecycle_via_session_cookie",
|
|
"AC4": "test_acceptance_p3.AcceptanceP3Tests.test_ac4_derivation_identical_both_sides_stored_as_slow_hash",
|
|
"AC5": "test_acceptance_p4.AcceptanceP4Tests.test_ac5_valid_token_reads_secret_tampered_token_is_401",
|
|
"AC6": "test_acceptance_p4.AcceptanceP4Tests.test_ac6_cross_path_read_is_403_with_denied_audit",
|
|
"AC7": "test_acceptance_p3.AcceptanceP3Tests.test_ac7_revoked_or_unknown_uuid_denied_existing_token_honored",
|
|
"AC8": "test_acceptance_p3.AcceptanceP3Tests.test_ac8_unknown_or_revoked_user_denied",
|
|
"AC9": "test_acceptance_p2.AcceptanceP2Tests.test_ac9_login_and_logout_audit_lines_with_null_identity",
|
|
"AC10": "test_acceptance_p3.AcceptanceP3Tests.test_ac10_expiry_401_reregister_refreshes_expiry_and_audits",
|
|
"AC11": "test_acceptance_full.AcceptanceFullTests.test_ac11_fresh_boot_state_files_0600_umask_independent",
|
|
"AC12": "test_acceptance_p3.AcceptanceP3Tests.test_ac12_state_roundtrip_with_identity_enforcement",
|
|
"AC13": "test_acceptance_full.AcceptanceFullTests.test_ac13_client_module_fetch_byte_identical_importable_standalone",
|
|
"AC14": "test_acceptance_full.AcceptanceFullTests.test_ac14_coreci_broker_line_count_under_1400",
|
|
}
|
|
|
|
|
|
def _basic_header(password):
|
|
raw = "%s:%s" % (ADMIN_USER, password)
|
|
return {"Authorization": "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")}
|
|
|
|
|
|
class AcceptanceFullTests(unittest.TestCase):
|
|
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(json.dumps({PROXMOX_PATH: PROXMOX_SECRET, GITEA_PATH: GITEA_SECRET}))
|
|
|
|
def _env(self, root=None, extra=None):
|
|
root = self.tmp if root is None else root
|
|
env = {
|
|
"BROKER_ENV_PATH": os.path.join(root, ".env"),
|
|
"BROKER_DB_PATH": os.path.join(root, "broker.db"),
|
|
"BROKER_AUDIT_LOG_PATH": os.path.join(root, "broker.audit.log"),
|
|
"BROKER_CREDS_SOURCE": "file",
|
|
"BROKER_CREDS_FILE": self.creds_path,
|
|
"BROKER_UI_ROOT": os.path.join(REPO_ROOT, "coreci_broker_ui"),
|
|
"BROKER_STATIC_ROOT": REPO_ROOT,
|
|
}
|
|
env.update(extra or {})
|
|
return env
|
|
|
|
def _boot(self, env):
|
|
buffer = io.StringIO()
|
|
with contextlib.redirect_stdout(buffer):
|
|
broker = coreci_broker.boot(dict(env))
|
|
self.addCleanup(broker.audit.close)
|
|
self.addCleanup(broker.store.close)
|
|
return broker, buffer.getvalue()
|
|
|
|
def _serve(self, broker):
|
|
server = coreci_broker.serve("127.0.0.1", 0, broker)
|
|
self.addCleanup(server.server_close)
|
|
self.addCleanup(server.shutdown)
|
|
return "http://127.0.0.1:%d" % server.server_address[1]
|
|
|
|
def _printed_pass(self, output):
|
|
printed = [
|
|
line.split("=", 1)[1]
|
|
for line in output.splitlines()
|
|
if line.startswith("BROKER_ADMIN_PASS=")
|
|
]
|
|
self.assertEqual(1, len(printed))
|
|
return printed[0]
|
|
|
|
def _request(self, base, method, path, payload=None, headers=None):
|
|
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
|
request = urllib.request.Request(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:
|
|
return response.status, response.read(), response.headers
|
|
except urllib.error.HTTPError as error:
|
|
return error.code, error.read(), error.headers
|
|
|
|
def _request_json(self, base, method, path, payload=None, headers=None):
|
|
status, body, response_headers = self._request(base, method, path, payload, headers)
|
|
return status, json.loads(body.decode("utf-8")), response_headers
|
|
|
|
def _assert_null_identity(self, entry):
|
|
self.assertIsNone(entry["user_id"])
|
|
self.assertIsNone(entry["app_registration_uuid"])
|
|
self.assertIsNone(entry["agent_id"])
|
|
|
|
def _assert_tool_identity(self, entry, app_uuid):
|
|
self.assertEqual("ceo", entry["user_id"])
|
|
self.assertEqual(app_uuid, entry["app_registration_uuid"])
|
|
self.assertEqual("proxmox", entry["agent_id"])
|
|
|
|
def _assert_state_files_0600(self, env):
|
|
for key in ("BROKER_DB_PATH", "BROKER_AUDIT_LOG_PATH", "BROKER_ENV_PATH"):
|
|
path = env[key]
|
|
self.assertTrue(os.path.isfile(path), path)
|
|
self.assertEqual(0o600, os.stat(path).st_mode & 0o777, path)
|
|
|
|
def test_ac11_fresh_boot_state_files_0600_umask_independent(self):
|
|
env = self._env()
|
|
self._boot(env)
|
|
self._assert_state_files_0600(env)
|
|
other = tempfile.TemporaryDirectory()
|
|
self.addCleanup(other.cleanup)
|
|
other_env = self._env(root=other.name)
|
|
previous_umask = os.umask(0o777)
|
|
try:
|
|
probe = os.path.join(other.name, "umask-probe")
|
|
os.close(os.open(probe, os.O_CREAT | os.O_WRONLY, 0o666))
|
|
self.assertEqual(0o000, os.stat(probe).st_mode & 0o777)
|
|
self._boot(other_env)
|
|
finally:
|
|
os.umask(previous_umask)
|
|
self._assert_state_files_0600(other_env)
|
|
|
|
def test_ac13_client_module_fetch_byte_identical_importable_standalone(self):
|
|
broker, _ = self._boot(self._env())
|
|
base = self._serve(broker)
|
|
with open(CLIENT_SOURCE_PATH, "rb") as handle:
|
|
expected = handle.read()
|
|
with urllib.request.urlopen(base + CLIENT_STATIC_PATH, timeout=10) as response:
|
|
self.assertEqual(200, response.status)
|
|
fetched = response.read()
|
|
self.assertEqual(expected, fetched)
|
|
imports = set()
|
|
for node in ast.walk(ast.parse(fetched.decode("utf-8"))):
|
|
if isinstance(node, ast.Import):
|
|
imports.update(alias.name for alias in node.names)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
imports.add(node.module or "")
|
|
self.assertTrue(imports)
|
|
for name in imports:
|
|
self.assertIn(name.split(".")[0], sys.stdlib_module_names, name)
|
|
copy_path = os.path.join(self.tmp, "fetched_coreci_broker_client.py")
|
|
with open(copy_path, "wb") as handle:
|
|
handle.write(fetched)
|
|
spec = importlib.util.spec_from_file_location("full_fetched_client", copy_path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
self.assertTrue(callable(module.BrokerClient))
|
|
self.assertTrue(callable(module.fetch_client_module))
|
|
for method in ("ensure_registered", "get_state", "put_state", "read_secret"):
|
|
self.assertTrue(callable(getattr(module.BrokerClient, method)), method)
|
|
|
|
def test_ac14_coreci_broker_line_count_under_1400(self):
|
|
with open(BROKER_SOURCE_PATH, "r", encoding="utf-8") as handle:
|
|
line_count = sum(1 for _ in handle)
|
|
self.assertLess(line_count, 1400)
|
|
|
|
def test_milestone_story_full_happy_path_consolidates_ac1_ac10_ac12(self):
|
|
env = self._env(extra={"BROKER_TOKEN_TTL": "1"})
|
|
first, output = self._boot(env)
|
|
printed_pass = self._printed_pass(output)
|
|
self.assertGreaterEqual(len(printed_pass), 32)
|
|
self.assertEqual(1, output.count(printed_pass))
|
|
pepper = None
|
|
with open(env["BROKER_ENV_PATH"], "r", encoding="utf-8") as handle:
|
|
for line in handle:
|
|
if line.startswith("BROKER_INSTANCE_PEPPER="):
|
|
pepper = line.split("=", 1)[1].strip()
|
|
self.assertIsNotNone(pepper)
|
|
self.assertNotIn(pepper, output)
|
|
second, silent = self._boot(env)
|
|
self.assertEqual("", silent)
|
|
second.audit.close()
|
|
second.store.close()
|
|
base = self._serve(first)
|
|
status, body, headers = self._request(base, "GET", "/ui/login")
|
|
self.assertEqual(200, status)
|
|
self.assertTrue(headers.get("Content-Type", "").startswith("text/html"))
|
|
self.assertIn('name="password"', body.decode("utf-8"))
|
|
status, body, response_headers = self._request_json(
|
|
base, "POST", LOGIN_PATH, {"username": ADMIN_USER, "password": printed_pass}
|
|
)
|
|
self.assertEqual(200, status)
|
|
self.assertEqual({"ok": True, "username": ADMIN_USER}, body)
|
|
raw_cookie = response_headers.get("Set-Cookie")
|
|
self.assertTrue(raw_cookie)
|
|
self.assertTrue(raw_cookie.startswith("broker_session="))
|
|
self.assertIn("HttpOnly", raw_cookie)
|
|
self.assertIn("SameSite=Lax", raw_cookie)
|
|
cookie = {"Cookie": raw_cookie.split(";", 1)[0]}
|
|
app_uuid = str(uuidlib.uuid4())
|
|
status, body, _ = self._request_json(
|
|
base, "POST", APPS_PATH,
|
|
{"uuid": app_uuid, "name": "proxmox-tool"}, headers=cookie,
|
|
)
|
|
self.assertEqual(201, status)
|
|
self.assertEqual({"uuid": app_uuid, "status": "active"}, body)
|
|
status, body, _ = self._request_json(
|
|
base, "POST", USERS_PATH,
|
|
{"user_id": "ceo", "email": "ceo@coreci.dev"}, headers=cookie,
|
|
)
|
|
self.assertEqual(201, status)
|
|
self.assertEqual({"user_id": "ceo", "status": "active"}, body)
|
|
with urllib.request.urlopen(base + CLIENT_STATIC_PATH, timeout=10) as response:
|
|
self.assertEqual(200, response.status)
|
|
client_source = response.read()
|
|
with open(CLIENT_SOURCE_PATH, "rb") as handle:
|
|
self.assertEqual(handle.read(), client_source)
|
|
copy_path = os.path.join(self.tmp, "story_coreci_broker_client.py")
|
|
with open(copy_path, "wb") as handle:
|
|
handle.write(client_source)
|
|
spec = importlib.util.spec_from_file_location("full_story_client", copy_path)
|
|
client_module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(client_module)
|
|
client = client_module.BrokerClient(base, "proxmox", app_uuid, "ceo", ["coreci"])
|
|
state = {}
|
|
client.ensure_registered(state)
|
|
self.assertEqual(44, len(state["current_token"]))
|
|
self.assertTrue(state["current_token"].isascii())
|
|
self.assertIn("pepper", state)
|
|
self.assertEqual("proxmox", state["agent_id"])
|
|
self.assertEqual(app_uuid, state["app_registration_uuid"])
|
|
self.assertEqual("ceo", state["user_id"])
|
|
self.assertTrue(client.put_state(state))
|
|
snapshot = dict(state)
|
|
self.assertEqual(snapshot, client.get_state(state))
|
|
document = client.read_secret(PROXMOX_PATH, state)
|
|
self.assertEqual({"path", "data", "version", "updated_at"}, set(document))
|
|
self.assertEqual(PROXMOX_PATH, document["path"])
|
|
self.assertEqual(PROXMOX_SECRET, document["data"])
|
|
with self.assertRaises(client_module.BrokerError) as caught:
|
|
client.read_secret(GITEA_PATH, state)
|
|
self.assertEqual(403, caught.exception.status)
|
|
expired_at = state["token_expires_at"]
|
|
time.sleep(1.2)
|
|
document = client.read_secret(PROXMOX_PATH, state)
|
|
self.assertEqual(PROXMOX_SECRET, document["data"])
|
|
self.assertGreater(
|
|
datetime.datetime.fromisoformat(state["token_expires_at"]),
|
|
datetime.datetime.fromisoformat(expired_at),
|
|
)
|
|
status, body, _ = self._request_json(base, "POST", LOGOUT_PATH, headers=cookie)
|
|
self.assertEqual(200, status)
|
|
self.assertEqual({"ok": True}, body)
|
|
status, body, _ = self._request_json(
|
|
base, "GET", AUDIT_PATH + "?limit=1000", headers=_basic_header(printed_pass)
|
|
)
|
|
self.assertEqual(200, status)
|
|
self.assertIsNone(body["next_since"])
|
|
entries = body["entries"]
|
|
|
|
def select(action, result=None):
|
|
return [
|
|
entry for entry in entries
|
|
if entry["action"] == action and (result is None or entry["result"] == result)
|
|
]
|
|
|
|
logins = select("login", "ok")
|
|
self.assertEqual(1, len(logins))
|
|
self.assertEqual(LOGIN_PATH, logins[0]["path"])
|
|
self._assert_null_identity(logins[0])
|
|
add_apps = select("add_app", "ok")
|
|
self.assertEqual(1, len(add_apps))
|
|
self.assertEqual(APPS_PATH + "/" + app_uuid, add_apps[0]["path"])
|
|
self._assert_null_identity(add_apps[0])
|
|
add_users = select("add_user", "ok")
|
|
self.assertEqual(1, len(add_users))
|
|
self.assertEqual(USERS_PATH + "/ceo", add_users[0]["path"])
|
|
self._assert_null_identity(add_users[0])
|
|
registers = select("register", "ok")
|
|
self.assertGreaterEqual(len(registers), 2)
|
|
for entry in registers:
|
|
self.assertEqual(REGISTER_PATH, entry["path"])
|
|
self._assert_tool_identity(entry, app_uuid)
|
|
ok_reads = select("read", "ok")
|
|
self.assertGreaterEqual(len(ok_reads), 2)
|
|
for entry in ok_reads:
|
|
self.assertEqual(PROXMOX_PATH, entry["path"])
|
|
self._assert_tool_identity(entry, app_uuid)
|
|
denied_reads = select("read", "denied")
|
|
self.assertEqual(1, len(denied_reads))
|
|
self.assertEqual(GITEA_PATH, denied_reads[0]["path"])
|
|
self._assert_tool_identity(denied_reads[0], app_uuid)
|
|
auth_denied = [
|
|
entry for entry in select("denied", "denied")
|
|
if entry["path"] == SECRETS_PREFIX + PROXMOX_PATH
|
|
]
|
|
self.assertGreaterEqual(len(auth_denied), 1)
|
|
self.assertEqual(app_uuid, auth_denied[0]["app_registration_uuid"])
|
|
self.assertIsNone(auth_denied[0]["user_id"])
|
|
self.assertIsNone(auth_denied[0]["agent_id"])
|
|
logouts = select("logout", "ok")
|
|
self.assertEqual(1, len(logouts))
|
|
self.assertEqual(LOGOUT_PATH, logouts[0]["path"])
|
|
self._assert_null_identity(logouts[0])
|
|
|
|
def test_coverage_tally_pins_ac1_through_ac14_across_suite(self):
|
|
self.assertEqual(14, len(AC_PIN_IDS))
|
|
loader = unittest.TestLoader()
|
|
suite = loader.discover(os.path.dirname(os.path.abspath(__file__)))
|
|
stack, ids = [suite], set()
|
|
while stack:
|
|
item = stack.pop()
|
|
if isinstance(item, unittest.TestSuite):
|
|
stack.extend(item)
|
|
else:
|
|
ids.add(item.id())
|
|
for ac in sorted(AC_PIN_IDS):
|
|
with self.subTest(ac=ac):
|
|
self.assertIn(AC_PIN_IDS[ac], ids)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main() |