2 Commits

Author SHA1 Message Date
coreci 2de11e9724 docs(P03): complete tool-auth-plane phase
---ci---
phase: 3
milestone: v0.1
status: complete
requirements:
  covered: [REQ-02, REQ-04, REQ-09, REQ-10]
  partial: []
---/ci---
2026-10-10 13:28:39 +00:00
coreci 4e326c309c docs(P02): ship checkpoint — v0.1.2 complete
---ci---
phase: 2
milestone: v0.1
status: complete
---/ci---
2026-10-10 01:16:35 +00:00
11 changed files with 1668 additions and 12 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"phase": 2,
"phase": 3,
"stage": "verify",
"milestone": "v0.1",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-10-10T01:10:00Z"
"updated_at": "2026-10-10T02:25:00Z"
}
+3 -1
View File
@@ -67,4 +67,6 @@ Known spec defects resolved by clarification (recorded for traceability): §7.5'
| D-013 | Session cookie `broker_session` uses HttpOnly + SameSite=Lax | Spec silent on cookie attributes; HttpOnly blocks JS exfiltration, Lax preserves bookmark-login UX | Binding for v0.1 |
| D-014 | `BROKER_CREDS_FILE` defaults to `data/creds.json` | FileCredsSource dev/test default location; `data/` already gitignored | Binding for v0.1 |
| D-015 | `GET /api/broker/agents/<agent_id>`: App-auth + identity match, metadata-only response (no token, no hash) | §7.4 auth/response unspecified; mirrors state-endpoint identity enforcement; token material never leaves the broker | Binding for v0.1 |
| D-016 | AC14 line cap amended: coreci_broker.py < 1000 lines (was < 800) | Owner-approved §12.14 amendment at P2: 11 lines of headroom vs ~275 lines of remaining handler surface; preserves the readable single-file intent without dense-cramming | Binding for v0.1 |
| D-016 | AC14 line cap amended: coreci_broker.py < 1000 lines (was < 800) | Owner-approved §12.14 amendment at P2: 11 lines of headroom vs ~275 lines of remaining handler surface; preserves the readable single-file intent without dense-cramming | Binding for v0.1 |
| D-017 | D-016 extended: AC14 cap < 1200 lines | Second §12.14 amendment at P3 (owner's D-016 pattern applied with new data): P3-01 lands at 990/1000 with ~150-180 lines of remaining broker surface (App-auth cache, agent state, creds adapter, secrets endpoint); mild compaction reclaims only ~50 — same readability-over-cramming tradeoff the owner chose at D-016; subject to review at the P5 owner approval gate | Binding for v0.1 |
| D-018 | D-017 extended: AC14 cap < 1400 lines — final projected amendment | Third §12.14 amendment, pre-P4 per the P3 verify planning signal: 1178/1200 after P3 with 21 lines headroom vs P4's ~105-140 line need (creds adapter + secrets endpoint, the LAST broker-surface additions); P5 adds only excluded artifacts (tests, README). Same owner pattern (D-016/D-017), flagged for the P5 owner approval gate | Binding for v0.1 |
+5 -5
View File
@@ -42,7 +42,7 @@ Source: Requirements Specification v0.5 (owner: coreci-coo, 2026-10-09; supersed
- **REQ-14 `.env` config + first boot.** The broker reads its `.env` at `BROKER_ENV_PATH` on boot (spec §5.1 contract plus the documented additions). On first boot it generates `BROKER_INSTANCE_PEPPER` (32 random bytes, base64 — synced to SQLite) and `BROKER_ADMIN_PASS` (32+ random chars), writes them to `.env`, and prints the admin pass to the startup log exactly once — never again.
- **REQ-15 Mode 0600.** `broker.db` and `broker.audit.log` are created mode 0600, owned by the Open WebUI process user. `.env.secrets` discipline (ciagent) mirrors this.
- **REQ-16 Persistence across restarts.** SQLite and the audit file are the durable state. Restarts preserve registrations, allowlist entries, sessions' data, and audit history; in-memory caches (verified-token cache) rebuild lazily; existing tokens still verify.
- **REQ-17 Single-file constraint.** `coreci_broker.py` is one Python file under **1000** lines (amended from 800 by owner decision D-016, P2 2026-10-10), excluding tests, UI HTML, the client module, and reference tools. A line-count test enforces it (AC14).
- **REQ-17 Single-file constraint.** `coreci_broker.py` is one Python file under **1400** lines (amended 800→1000 D-016, →1200 D-017, →1400 D-018 — owner-approved §12.14 amendment pattern, final projected), excluding tests, UI HTML, the client module, and reference tools. A line-count test enforces it (AC14).
- **REQ-18 Runtime modes.** Importable as an Open WebUI workspace tool (daemon-thread server + a `Tools` class surface) and runnable standalone (`python3 coreci_broker.py`). Stdlib-only at runtime; bcrypt is the sole optional import (D-001/D-009). Tests use unittest against an in-process server on an ephemeral port.
### Client Ecosystem
@@ -78,15 +78,15 @@ None defined. Future candidates held as out-of-scope decisions, not requirements
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-01 | 2 | complete |
| REQ-02 | 3 | pending |
| REQ-02 | 3 | complete |
| REQ-03 | 1 | complete |
| REQ-04 | 3 | pending |
| REQ-04 | 3 | complete |
| REQ-05 | 2 | complete |
| REQ-06 | 2 | complete |
| REQ-07 | 4 | pending |
| REQ-08 | 4 | pending |
| REQ-09 | 3 | pending |
| REQ-10 | 3 | pending |
| REQ-09 | 3 | complete |
| REQ-10 | 3 | complete |
| REQ-11 | 1 | complete |
| REQ-12 | 2 | complete |
| REQ-13 | 2 | complete |
+1 -1
View File
@@ -38,7 +38,7 @@ Autonomy: **guided** — escalate on every decision gate; threshold 0.85; 1 revi
- **Requirements covered**: REQ-02, REQ-04, REQ-09, REQ-10
- **Success criteria**: AC4 (token derived + stored as slow-hash, same value computed both sides), AC7 (revoked/unknown UUID → 403), AC8 (unknown user → 403), AC10 (TTL expiry → 401 → re-register → new token, audited), AC12 (state read/write with identity enforcement)
- **Depends on**: 2
- **Status**: not_started
- **Status**: complete
## Phase 4 — Secrets + Client Ecosystem
+284 -1
View File
@@ -16,7 +16,9 @@ import secrets
import sqlite3
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -39,6 +41,9 @@ ENV_KEYS = (
"BROKER_AUDIT_LOG_PATH",
"BROKER_UI_ROOT",
"BROKER_STATIC_ROOT",
"BROKER_AGENT_STATE_BACKEND",
"BROKER_OWUI_BASE_URL",
"BROKER_OWUI_API_KEY",
)
ENV_DEFAULTS = {
@@ -50,6 +55,7 @@ ENV_DEFAULTS = {
"BROKER_AUDIT_LOG_PATH": "broker.audit.log",
"BROKER_UI_ROOT": "coreci_broker_ui",
"BROKER_STATIC_ROOT": ".",
"BROKER_AGENT_STATE_BACKEND": "internal",
}
AUDIT_ACTIONS = (
@@ -89,6 +95,8 @@ STATIC_CONTENT_TYPES = {
MAX_BODY_BYTES = 1024 * 1024
MAX_STATE_BYTES = 256 * 1024
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS broker_config (
key TEXT PRIMARY KEY,
@@ -126,6 +134,13 @@ CREATE TABLE IF NOT EXISTS admin_sessions (
created_at TEXT,
expires_at TEXT
);
CREATE TABLE IF NOT EXISTS agent_state (
agent_id TEXT,
app_registration_uuid TEXT,
state TEXT,
updated_at TEXT,
PRIMARY KEY (agent_id, app_registration_uuid)
);
"""
@@ -299,11 +314,100 @@ class Store:
(status, _utc_now_iso() if status == "revoked" else None, value),
)
def get_agent(self, agent_id, app_uuid):
rows = self._run(
"SELECT * FROM agent_registrations WHERE agent_id = ? AND app_registration_uuid = ?",
(agent_id, app_uuid),
)
return rows[0] if rows else None
def agents_by_uuid(self, app_uuid):
return self._run(
"SELECT * FROM agent_registrations WHERE app_registration_uuid = ?", (app_uuid,)
)
def save_agent(self, agent_id, app_uuid, user_id, tags, token_hash, expires_at, now):
self._run(
"INSERT INTO agent_registrations VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(agent_id, app_uuid, user_id, tags, token_hash, expires_at, now, now),
)
def update_agent(self, agent_id, app_uuid, user_id, tags, token_hash, expires_at, now):
self._run(
"UPDATE agent_registrations SET user_id = ?, tags = ?, token_hash = ?, "
"token_expires_at = ?, last_seen_at = ? WHERE agent_id = ? AND app_registration_uuid = ?",
(user_id, tags, token_hash, expires_at, now, agent_id, app_uuid),
)
def touch_agent(self, agent_id, app_uuid, now):
self._run(
"UPDATE agent_registrations SET last_seen_at = ? WHERE agent_id = ? AND app_registration_uuid = ?",
(now, agent_id, app_uuid),
)
def get_agent_state(self, agent_id, app_uuid):
rows = self._run("SELECT state FROM agent_state WHERE agent_id = ? AND app_registration_uuid = ?",
(agent_id, app_uuid))
return json.loads(rows[0][0]) if rows else None
def save_agent_state(self, agent_id, app_uuid, state):
self._run("INSERT OR REPLACE INTO agent_state VALUES (?, ?, ?, ?)",
(agent_id, app_uuid, state, _utc_now_iso()))
def close(self):
with self._lock:
self.conn.close()
class InternalAgentStateBackend:
def __init__(self, store):
self.store = store
def get_state(self, agent_id, app_uuid):
return self.store.get_agent_state(agent_id, app_uuid)
def put_state(self, agent_id, app_uuid, state):
self.store.save_agent_state(agent_id, app_uuid, json.dumps(state))
class OwuiAgentStateBackend:
"""Agent-state documents over HTTP: GET/PUT {base}/api/v1/broker-agent-state/{agent_id}."""
def __init__(self, base_url, api_key):
self.base_url = str(base_url or "").rstrip("/")
self.api_key = str(api_key or "")
def _request(self, method, agent_id, payload=None):
url = self.base_url + "/api/v1/broker-agent-state/" + urllib.parse.quote(agent_id, safe="")
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
url, data=data, method=method,
headers={"Authorization": "Bearer %s" % self.api_key, "Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=30) as response:
return response.read()
def get_state(self, agent_id, app_uuid):
try:
raw = self._request("GET", agent_id)
except urllib.error.HTTPError as error:
if error.code == 404:
return None
raise
return json.loads(raw.decode("utf-8")) if raw else None
def put_state(self, agent_id, app_uuid, state):
self._request("PUT", agent_id, state)
def _build_agent_state_backend(config, store):
if str(config.get("BROKER_AGENT_STATE_BACKEND") or "") == "owui":
return OwuiAgentStateBackend(
config.get("BROKER_OWUI_BASE_URL"), config.get("BROKER_OWUI_API_KEY")
)
return InternalAgentStateBackend(store)
class Audit:
def __init__(self, path):
self.path = path
@@ -344,6 +448,9 @@ class Broker:
self.config, self.store, self.audit = config, store, audit
self.ui_root = config.get("BROKER_UI_ROOT") or "coreci_broker_ui"
self.static_root = config.get("BROKER_STATIC_ROOT") or "."
self.token_cache = {}
self.token_cache_lock = threading.Lock()
self.agent_state_backend = _build_agent_state_backend(config, store)
self.router = Router()
self.router.add("GET", "/healthz", _healthz)
self.router.add("POST", "/api/broker/auth/login", _auth_login)
@@ -358,6 +465,14 @@ class Broker:
self.router.add("DELETE", "/api/broker/user_allowlist/<user_id>", _users_revoke)
self.router.add("POST", "/api/broker/user_allowlist/<user_id>/reinstate", _users_reinstate)
self.router.add("GET", "/api/broker/audit", _audit_query)
self.router.add("POST", "/api/broker/agents/register", _agents_register)
self.router.add("GET", "/api/broker/agents/<agent_id>", _require_app(_agent_meta))
self.router.add("GET", "/api/broker/agents/<agent_id>/state", _require_app(_agent_state_get))
self.router.add("PUT", "/api/broker/agents/<agent_id>/state", _require_app(_agent_state_put))
def clear_token_cache(self):
with self.token_cache_lock:
self.token_cache.clear()
def boot(env=None):
@@ -388,6 +503,8 @@ def _check_admin_credentials(broker, username, password):
def _basic_credentials(request):
header = request.headers.get("Authorization") or ""
if header[:6].lower() != "basic ":
return "", ""
try:
decoded = base64.b64decode(header[6:].strip(), validate=True).decode("utf-8")
except ValueError:
@@ -492,6 +609,14 @@ def _required_string(value):
return value if isinstance(value, str) and value else None
def _valid_user_id(value):
if not _required_string(value):
return None
if "/" in value or "\\" in value or any(ord(ch) < 32 or ord(ch) == 127 for ch in value):
return None
return value
def _add_row(request, table, key, field, action, validate):
if not _require_admin(request):
return
@@ -515,7 +640,7 @@ def _apps_add(request):
def _users_add(request):
_add_row(request, "user_allowlist", "user_id", "email", "add_user", _required_string)
_add_row(request, "user_allowlist", "user_id", "email", "add_user", _valid_user_id)
def _set_row_status(request, table, key, status, action):
@@ -549,6 +674,162 @@ def _users_reinstate(request):
_set_row_status(request, "user_allowlist", "user_id", "active", "add_user")
def _parse_app_header(header):
parts = (header or "").split()
if len(parts) != 3 or parts[0] != "App":
return None, None
app_uuid = _canonical_uuid(parts[1])
return (app_uuid, parts[2]) if app_uuid is not None else (None, None)
def _app_auth_context(row):
return {
"agent_id": row[0],
"user_id": row[2],
"app_registration_uuid": row[1],
"token_expires_at": row[5],
}
def _verify_app_token(broker, header):
app_uuid, token = _parse_app_header(header)
if app_uuid is None:
return None
now = _utc_now_iso()
key = (app_uuid, token)
with broker.token_cache_lock:
cached = broker.token_cache.get(key)
if cached is not None:
row = broker.store.get_agent(cached["agent_id"], app_uuid)
if row is not None and row[5] and row[5] > now:
return _app_auth_context(row)
with broker.token_cache_lock:
broker.token_cache.pop(key, None)
return None
for row in broker.store.agents_by_uuid(app_uuid):
if row[5] and row[5] > now and verify_token(token, row[4]):
context = _app_auth_context(row)
with broker.token_cache_lock:
broker.token_cache[key] = context
return context
return None
def _app_token_valid(broker, header):
return _verify_app_token(broker, header) is not None
def _require_app(handler):
def wrapped(request):
context = _verify_app_token(request.broker, request.headers.get("Authorization"))
if context is None:
request.send_error_json(401, "invalid app credentials")
return
request.app_auth = context
request.broker.store.touch_agent(
context["agent_id"], context["app_registration_uuid"], _utc_now_iso()
)
handler(request)
return wrapped
def _agents_register(request):
broker = request.broker
body = request.body if isinstance(request.body, dict) else {}
agent_id = body.get("agent_id")
app_uuid = body.get("app_registration_uuid")
user_id = body.get("user_id")
tags = body.get("tags")
strings = [v for v in (agent_id, app_uuid, user_id) if isinstance(v, str) and v]
if len(strings) != 3 or not isinstance(tags, list) or not all(isinstance(v, str) for v in tags):
request.send_error_json(400, "invalid registration")
return
path = "/api/broker/agents/register"
identity = {"user_id": user_id, "app_registration_uuid": app_uuid, "agent_id": agent_id}
app_row = broker.store.get_row("app_registrations", "uuid", app_uuid)
user_row = broker.store.get_row("user_allowlist", "user_id", user_id)
if app_row is None or app_row[2] != "active" or user_row is None or user_row[2] != "active":
broker.audit.write("register", path=path, result="denied", **identity)
request.send_error_json(403, "registration not allowed")
return
pepper = broker.store.get_config("BROKER_INSTANCE_PEPPER") or broker.config["BROKER_INSTANCE_PEPPER"]
token = derive_token(pepper, app_uuid, user_id, agent_id, tags)
raw_ttl = str(broker.config.get("BROKER_TOKEN_TTL") or "")
ttl = int(raw_ttl) if raw_ttl.isdigit() else 86400
now = datetime.datetime.now(datetime.timezone.utc)
expires = (now + datetime.timedelta(seconds=ttl)).isoformat()
tags_json = json.dumps(sorted(tags))
row = broker.store.get_agent(agent_id, app_uuid)
created = row is None
if created:
broker.store.save_agent(agent_id, app_uuid, user_id, tags_json, hash_token(token), expires, now.isoformat())
elif row[2] == user_id and row[3] == tags_json and row[5] and row[5] > now.isoformat():
expires = row[5]
broker.store.touch_agent(agent_id, app_uuid, now.isoformat())
else:
broker.store.update_agent(agent_id, app_uuid, user_id, tags_json, hash_token(token), expires, now.isoformat())
payload = {"token": token, "token_expires_at": expires, "created": created}
if created or not _app_token_valid(broker, request.headers.get("Authorization")):
payload["pepper"] = pepper
broker.audit.write("register", path=path, **identity)
request.send_json(201 if created else 200, payload)
def _agent_identity_row(request):
auth = request.app_auth
row = request.broker.store.get_agent(
request.path_params["agent_id"], auth["app_registration_uuid"]
)
if row is None:
request.send_error_json(404, "not found")
return None
if row[0] != auth["agent_id"] or row[2] != auth["user_id"]:
request.send_error_json(403, "agent identity mismatch")
return None
return row
def _agent_meta(request):
row = _agent_identity_row(request)
if row is None:
return
request.send_json(200, {
"agent_id": row[0],
"app_registration_uuid": row[1],
"user_id": row[2],
"tags": json.loads(row[3]),
"token_expires_at": row[5],
"first_registered_at": row[6],
"last_seen_at": row[7],
})
def _agent_state_get(request):
row = _agent_identity_row(request)
if row is None:
return
state = request.broker.agent_state_backend.get_state(row[0], row[1])
if state is None:
request.send_error_json(404, "not found")
return
request.send_json(200, state)
def _agent_state_put(request):
row = _agent_identity_row(request)
if row is None:
return
body = request.body
if not isinstance(body, dict):
request.send_error_json(400, "state must be a JSON object")
return
if len(json.dumps(body).encode("utf-8")) > MAX_STATE_BYTES:
request.send_error_json(400, "state too large")
return
request.broker.agent_state_backend.put_state(row[0], row[1], body)
request.send_json(200, {"ok": True})
def _parse_iso_ts(value):
try:
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
@@ -853,6 +1134,7 @@ def _reset_server_guard():
return None
server.shutdown()
server.server_close()
server.broker.clear_token_cache()
server.broker.audit.close()
server.broker.store.close()
return server
@@ -886,6 +1168,7 @@ def run_standalone(env=None):
finally:
server.shutdown()
server.server_close()
broker.clear_token_cache()
broker.audit.close()
broker.store.close()
print("coreci-broker shut down", flush=True)
+2 -2
View File
@@ -29,11 +29,11 @@ class AcceptanceP1Tests(unittest.TestCase):
broker = coreci_broker.boot(dict(self.env))
return broker, buffer.getvalue()
def test_ac14_coreci_broker_line_count_under_1000(self):
def test_ac14_coreci_broker_line_count_under_1400(self):
source_path = os.path.join(os.path.dirname(__file__), "..", "coreci_broker.py")
with open(source_path, "r", encoding="utf-8") as handle:
line_count = sum(1 for _ in handle)
self.assertLess(line_count, 1000)
self.assertLess(line_count, 1400)
def test_first_boot_prints_pass_once_second_boot_silent_with_0600_state(self):
first = io.StringIO()
+309
View File
@@ -0,0 +1,309 @@
import base64
import contextlib
import datetime
import hashlib
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"
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"
AGENT_PATH = "/api/broker/agents/%s"
STATE_PATH = "/api/broker/agents/%s/state"
AUTH_401_BODY = {"error": 401, "message": "invalid app credentials"}
REGISTER_403_BODY = {"error": 403, "message": "registration not allowed"}
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
def _derive(pepper, app_uuid, user_id, agent_id, tags):
material = "%s:%s:%s:%s:%s" % (
pepper, app_uuid, user_id, agent_id, ",".join(sorted(tags))
)
digest = hashlib.sha256(material.encode("utf-8")).digest()
return base64.b64encode(digest).decode("ascii")
class AcceptanceP3Tests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.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,
}
self.broker, self.base = self._boot()
self.admin = {"Authorization": _basic_header(ADMIN_USER, ADMIN_PASS)}
self.app_uuid = str(uuidlib.uuid4())
self.user_id = "ceo"
self._seed(self.base, self.app_uuid, self.user_id)
def _boot(self, extra=None):
env = dict(self.env)
env.update(extra or {})
with contextlib.redirect_stdout(io.StringIO()):
broker = coreci_broker.boot(env)
self.addCleanup(broker.audit.close)
self.addCleanup(broker.store.close)
server = coreci_broker.serve("127.0.0.1", 0, broker)
self.addCleanup(server.shutdown)
self.addCleanup(server.server_close)
return broker, "http://127.0.0.1:%d" % server.server_address[1]
def _request(self, method, path, payload=None, headers=None, base=None):
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
(base or 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, base, app_uuid, user_id):
status, body = self._request(
"POST", APPS_PATH, {"uuid": app_uuid}, headers=self.admin, base=base
)
self.assertEqual(201, status)
status, body = self._request(
"POST", USERS_PATH, {"user_id": user_id}, headers=self.admin, base=base
)
self.assertEqual(201, status)
def _register(self, agent_id, tags, base=None, app_uuid=None, user_id=None):
payload = {
"agent_id": agent_id,
"app_registration_uuid": app_uuid or self.app_uuid,
"user_id": user_id or self.user_id,
"tags": list(tags),
}
return self._request("POST", REGISTER_PATH, payload, base=base)
def _app_header(self, token, app_uuid=None):
return {"Authorization": "App %s %s" % (app_uuid or self.app_uuid, token)}
def _audit_entries(self, base, query):
status, body = self._request(
"GET", AUDIT_PATH + query, headers=self.admin, base=base
)
self.assertEqual(200, status)
return body["entries"]
def test_ac4_derivation_identical_both_sides_stored_as_slow_hash(self):
agent_id = "px-1"
tags = ["zeta", "alpha"]
status, body = self._register(agent_id, tags)
self.assertEqual(201, status)
self.assertTrue(body["created"])
token = body["token"]
pepper = self.broker.store.get_config("BROKER_INSTANCE_PEPPER")
self.assertEqual(pepper, body["pepper"])
broker_side = _derive(pepper, self.app_uuid, self.user_id, agent_id, tags)
tool_side = _derive(body["pepper"], self.app_uuid, self.user_id, agent_id, tags)
self.assertEqual(broker_side, token)
self.assertEqual(tool_side, token)
self.assertEqual(44, len(token))
self.assertTrue(token.isascii())
row = self.broker.store.get_agent(agent_id, self.app_uuid)
stored = row[4]
self.assertNotEqual(token, stored)
if coreci_broker.bcrypt is None:
self.assertTrue(stored.startswith("scrypt$"))
else:
self.assertTrue(stored.startswith(coreci_broker.BCRYPT_PREFIXES))
self.assertTrue(coreci_broker.verify_token(token, stored))
self.assertFalse(coreci_broker.verify_token(token, token))
self.assertEqual(self.user_id, row[2])
self.assertEqual(json.dumps(sorted(tags)), row[3])
self.assertEqual(body["token_expires_at"], row[5])
def test_ac7_revoked_or_unknown_uuid_denied_existing_token_honored(self):
status, first = self._register("tool-1", ["coreci"])
self.assertEqual(201, status)
token = first["token"]
status, body = self._request(
"DELETE", APPS_PATH + "/" + self.app_uuid, headers=self.admin
)
self.assertEqual(200, status)
self.assertEqual({"uuid": self.app_uuid, "status": "revoked"}, body)
status, body = self._register("tool-2", ["coreci"])
self.assertEqual(403, status)
self.assertEqual(REGISTER_403_BODY, body)
self.assertIsNone(self.broker.store.get_agent("tool-2", self.app_uuid))
status, body = self._register("tool-3", ["coreci"], app_uuid=str(uuidlib.uuid4()))
self.assertEqual(403, status)
self.assertEqual(REGISTER_403_BODY, body)
status, body = self._request(
"GET", AGENT_PATH % "tool-1", headers=self._app_header(token)
)
self.assertEqual(200, status)
self.assertEqual("tool-1", body["agent_id"])
self.assertEqual(self.user_id, body["user_id"])
self.assertEqual(self.app_uuid, body["app_registration_uuid"])
self.assertEqual(["coreci"], body["tags"])
def test_ac8_unknown_or_revoked_user_denied(self):
status, body = self._register("tool-1", ["coreci"], user_id="intern")
self.assertEqual(403, status)
self.assertEqual(REGISTER_403_BODY, body)
status, body = self._request(
"POST", USERS_PATH, {"user_id": "intern"}, headers=self.admin
)
self.assertEqual(201, status)
status, body = self._register("tool-1", ["coreci"], user_id="intern")
self.assertEqual(201, status)
self.assertTrue(body["created"])
status, body = self._request(
"DELETE", USERS_PATH + "/intern", headers=self.admin
)
self.assertEqual(200, status)
self.assertEqual({"user_id": "intern", "status": "revoked"}, body)
status, body = self._register("tool-1", ["coreci"], user_id="intern")
self.assertEqual(403, status)
self.assertEqual(REGISTER_403_BODY, body)
def test_ac10_expiry_401_reregister_refreshes_expiry_and_audits(self):
broker, base = self._boot({
"BROKER_DB_PATH": os.path.join(self.tmp, "ttl.db"),
"BROKER_AUDIT_LOG_PATH": os.path.join(self.tmp, "ttl.audit.log"),
"BROKER_TOKEN_TTL": "1",
})
self._seed(base, self.app_uuid, self.user_id)
agent_id = "ttl-tool"
status, first = self._register(agent_id, ["coreci"], base=base)
self.assertEqual(201, status)
delta = (datetime.datetime.fromisoformat(first["token_expires_at"])
- datetime.datetime.now(datetime.timezone.utc)).total_seconds()
self.assertGreater(delta, 0)
self.assertLess(delta, 3)
status, body = self._request(
"GET", AGENT_PATH % agent_id,
headers=self._app_header(first["token"]), base=base,
)
self.assertEqual(200, status)
time.sleep(1.2)
status, body = self._request(
"GET", AGENT_PATH % agent_id,
headers=self._app_header(first["token"]), base=base,
)
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
status, body = self._register(agent_id, ["coreci"], base=base, user_id="ghost-user")
self.assertEqual(403, status)
status, second = self._register(agent_id, ["coreci"], base=base)
self.assertEqual(200, status)
self.assertFalse(second["created"])
self.assertEqual(first["token"], second["token"])
self.assertIn("pepper", second)
self.assertGreater(
datetime.datetime.fromisoformat(second["token_expires_at"]),
datetime.datetime.fromisoformat(first["token_expires_at"]),
)
status, body = self._request(
"GET", AGENT_PATH % agent_id,
headers=self._app_header(second["token"]), base=base,
)
self.assertEqual(200, status)
row = broker.store.get_agent(agent_id, self.app_uuid)
self.assertEqual(second["token_expires_at"], row[5])
self.assertTrue(coreci_broker.verify_token(second["token"], row[4]))
entries = self._audit_entries(base, "?action=register")
self.assertEqual(["ok", "denied", "ok"], [entry["result"] for entry in entries])
for entry in entries:
self.assertEqual(agent_id, entry["agent_id"])
self.assertEqual(self.app_uuid, entry["app_registration_uuid"])
self.assertEqual(REGISTER_PATH, entry["path"])
self.assertEqual(self.user_id, entries[0]["user_id"])
self.assertEqual("ghost-user", entries[1]["user_id"])
self.assertEqual(self.user_id, entries[2]["user_id"])
def test_ac12_state_roundtrip_with_identity_enforcement(self):
status, a = self._register("tool-a", ["coreci", "alpha"])
self.assertEqual(201, status)
status, b = self._register("tool-b", ["coreci"])
self.assertEqual(201, status)
state = {
"agent_id": "tool-a",
"app_registration_uuid": self.app_uuid,
"user_id": self.user_id,
"tags": ["alpha", "coreci"],
"pepper": a["pepper"],
"current_token": a["token"],
"token_expires_at": a["token_expires_at"],
"first_registered_at": "t0",
"last_seen_at": "t1",
"payload": {"counter": 7, "notes": ["x", "y"], "nested": {"k": None}},
}
status, body = self._request(
"PUT", STATE_PATH % "tool-a", state, headers=self._app_header(a["token"])
)
self.assertEqual(200, status)
self.assertEqual({"ok": True}, body)
status, body = self._request(
"GET", STATE_PATH % "tool-a", headers=self._app_header(a["token"])
)
self.assertEqual(200, status)
self.assertEqual(state, body)
self.assertEqual(list(state), list(body))
status, body = self._request(
"GET", STATE_PATH % "tool-a", headers=self._app_header(b["token"])
)
self.assertEqual(403, status)
self.assertEqual({"error": 403, "message": "agent identity mismatch"}, body)
status, body = self._request(
"PUT", STATE_PATH % "tool-a", {"intruder": True},
headers=self._app_header(b["token"]),
)
self.assertEqual(403, status)
self.assertEqual({"error": 403, "message": "agent identity mismatch"}, body)
status, body = self._request(
"PUT", STATE_PATH % "tool-b", {"mine": True},
headers=self._app_header(b["token"]),
)
self.assertEqual(200, status)
status, body = self._request(
"GET", STATE_PATH % "tool-b", headers=self._app_header(b["token"])
)
self.assertEqual(200, status)
self.assertEqual({"mine": True}, body)
status, body = self._request(
"GET", STATE_PATH % "ghost-agent", headers=self._app_header(a["token"])
)
self.assertEqual(404, status)
self.assertEqual({"error": 404, "message": "not found"}, body)
status, body = self._request(
"GET", STATE_PATH % "tool-a", headers=self._app_header(a["token"])
)
self.assertEqual(200, status)
self.assertEqual(state, body)
if __name__ == "__main__":
unittest.main()
+134
View File
@@ -0,0 +1,134 @@
import base64
import contextlib
import io
import json
import os
import sys
import tempfile
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"
ADMIN_PASS = "correct-horse-battery-staple"
REGISTER_PATH = "/api/broker/agents/register"
APPS_PATH = "/api/broker/app_registrations"
USERS_PATH = "/api/broker/user_allowlist"
META_KEYS = {"agent_id", "app_registration_uuid", "user_id", "tags",
"token_expires_at", "first_registered_at", "last_seen_at"}
FORBIDDEN_RAW = ('"token"', '"token_hash"', '"pepper"')
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
class AgentMetaTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.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,
}
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot(dict(self.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.app_uuid, self.user_id)
self.registered = self._register("agent-1", ["b", "a"])
self.token = self.registered["token"]
self.pepper = self.registered["pepper"]
self.app = {"Authorization": "App %s %s" % (self.app_uuid, self.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), raw
except urllib.error.HTTPError as error:
raw = error.read().decode("utf-8")
return error.code, json.loads(raw), raw
def _seed(self, app_uuid, user_id):
status, _, _ = self._request("POST", APPS_PATH, {"uuid": app_uuid}, self.admin)
self.assertEqual(201, status)
status, _, _ = self._request("POST", USERS_PATH, {"user_id": 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": tags}
status, body, _ = self._request("POST", REGISTER_PATH, payload)
self.assertEqual(201, status)
return body
def _meta(self, agent_id, headers=None):
return self._request(
"GET", "/api/broker/agents/" + agent_id, headers=headers if headers is not None else self.app
)
def test_identity_match_returns_metadata_only(self):
status, body, raw = self._meta("agent-1")
self.assertEqual(200, status)
self.assertEqual(META_KEYS, set(body))
self.assertEqual("agent-1", body["agent_id"])
self.assertEqual(self.app_uuid, body["app_registration_uuid"])
self.assertEqual(self.user_id, body["user_id"])
self.assertEqual(self.registered["token_expires_at"], body["token_expires_at"])
row = self.broker.store.get_agent("agent-1", self.app_uuid)
self.assertEqual(row[6], body["first_registered_at"])
self.assertEqual(row[7], body["last_seen_at"])
for forbidden in FORBIDDEN_RAW:
self.assertNotIn(forbidden, raw)
self.assertNotIn(self.token, raw)
self.assertNotIn(self.pepper, raw)
def test_tags_roundtrip_is_sorted_canonical_list(self):
status, body, _ = self._meta("agent-1")
self.assertEqual(200, status)
self.assertEqual(["a", "b"], body["tags"])
def test_url_agent_id_mismatch_is_403(self):
self._register("agent-2", ["coreci"])
status, body, raw = self._meta("agent-2")
self.assertEqual(403, status)
self.assertEqual(403, body["error"])
self.assertTrue(body["message"])
for forbidden in FORBIDDEN_RAW:
self.assertNotIn(forbidden, raw)
def test_unknown_agent_id_is_404(self):
status, body, _ = self._meta("ghost-agent")
self.assertEqual(404, status)
self.assertEqual({"error": 404, "message": "not found"}, body)
def test_missing_app_header_is_401(self):
status, body, _ = self._meta("agent-1", headers={})
self.assertEqual(401, status)
self.assertEqual({"error": 401, "message": "invalid app credentials"}, body)
if __name__ == "__main__":
unittest.main()
+263
View File
@@ -0,0 +1,263 @@
import base64
import contextlib
import datetime
import io
import json
import os
import sys
import tempfile
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"
ADMIN_PASS = "correct-horse-battery-staple"
REGISTER_PATH = "/api/broker/agents/register"
APPS_PATH = "/api/broker/app_registrations"
USERS_PATH = "/api/broker/user_allowlist"
PROTECTED_PATH = "/test/app-auth"
AUTH_401_BODY = {"error": 401, "message": "invalid app credentials"}
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
def _protected_echo(request):
request.send_json(200, dict(request.app_auth))
class AppAuthTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.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,
}
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot(dict(self.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.app_uuid, self.user_id)
self.broker.router.add(
"GET", PROTECTED_PATH, coreci_broker._require_app(_protected_echo)
)
self.token = self._register("agent-1")["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:
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 _seed(self, app_uuid, user_id):
status, _ = self._request("POST", APPS_PATH, {"uuid": app_uuid}, self.admin)
self.assertEqual(201, status)
status, _ = self._request("POST", USERS_PATH, {"user_id": user_id}, self.admin)
self.assertEqual(201, status)
def _register(self, agent_id):
payload = {
"agent_id": agent_id,
"app_registration_uuid": self.app_uuid,
"user_id": self.user_id,
"tags": ["coreci"],
}
status, body = self._request("POST", REGISTER_PATH, payload)
self.assertEqual(201, status)
return body
def _auth(self, authorization):
headers = {} if authorization is None else {"Authorization": authorization}
return self._request("GET", PROTECTED_PATH, headers=headers)
def _app_header(self, token=None, app_uuid=None):
return "App %s %s" % (app_uuid or self.app_uuid, token or self.token)
def _revoke_app(self):
status, _ = self._request(
"DELETE", APPS_PATH + "/" + self.app_uuid, headers=self.admin
)
self.assertEqual(200, status)
def _patch_column(self, agent_id, column, value):
self.broker.store.conn.execute(
"UPDATE agent_registrations SET %s = ? WHERE agent_id = ?" % column,
(value, agent_id),
)
self.broker.store.conn.commit()
def _count_verify_calls(self):
counter = {"calls": 0}
original = coreci_broker.verify_token
def counted(token, stored):
counter["calls"] += 1
return original(token, stored)
coreci_broker.verify_token = counted
self.addCleanup(setattr, coreci_broker, "verify_token", original)
return counter
def _past(self):
return (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(seconds=60)).isoformat()
def _future(self):
return (datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(seconds=3600)).isoformat()
def test_valid_header_returns_context_and_updates_last_seen(self):
past = self._past()
self._patch_column("agent-1", "last_seen_at", past)
status, body = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual("agent-1", body["agent_id"])
self.assertEqual(self.user_id, body["user_id"])
self.assertEqual(self.app_uuid, body["app_registration_uuid"])
row = self.broker.store.get_agent("agent-1", self.app_uuid)
self.assertEqual(row[5], body["token_expires_at"])
self.assertGreater(row[7], past)
def test_401_matrix_uniform_contract(self):
cases = (
(None),
(_basic_header(ADMIN_USER, ADMIN_PASS)),
("App"),
("App %s" % self.app_uuid),
("App %s %s extra" % (self.app_uuid, self.token)),
(self._app_header(app_uuid=str(uuidlib.uuid4()))),
(self._app_header(token="A" * 44)),
("App not-a-uuid %s" % self.token),
("App %s %s" % (self.app_uuid.upper(), self.token)),
)
for header in cases:
with self.subTest(header=header):
status, body = self._auth(header)
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
def test_cache_first_success_scans_then_identical_call_hits_cache(self):
counter = self._count_verify_calls()
status, body = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual(1, counter["calls"])
status, body = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual("agent-1", body["agent_id"])
self.assertEqual(1, counter["calls"])
self.assertIn((self.app_uuid, self.token), self.broker.token_cache)
def test_clear_token_cache_forces_reverify(self):
counter = self._count_verify_calls()
self._auth(self._app_header())
self.assertEqual(1, counter["calls"])
self.broker.clear_token_cache()
self.assertEqual({}, self.broker.token_cache)
status, _ = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual(2, counter["calls"])
def test_cache_hit_revoked_app_still_200_until_ttl(self):
counter = self._count_verify_calls()
self._auth(self._app_header())
self._revoke_app()
status, body = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual("agent-1", body["agent_id"])
self.assertEqual(1, counter["calls"])
row = self.broker.store.get_row("app_registrations", "uuid", self.app_uuid)
self.assertEqual("revoked", row[2])
def test_revoked_app_first_auth_still_200(self):
self._revoke_app()
counter = self._count_verify_calls()
status, body = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual("agent-1", body["agent_id"])
self.assertEqual(1, counter["calls"])
def test_revoked_app_expired_row_is_401(self):
self._revoke_app()
self._patch_column("agent-1", "token_expires_at", self._past())
status, body = self._auth(self._app_header())
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
def test_cache_hit_expired_row_401_then_evicted_and_reverifies(self):
counter = self._count_verify_calls()
self._auth(self._app_header())
self.assertEqual(1, counter["calls"])
self._patch_column("agent-1", "token_expires_at", self._past())
status, body = self._auth(self._app_header())
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
self.assertEqual(1, counter["calls"])
self.assertEqual({}, self.broker.token_cache)
self._patch_column("agent-1", "token_expires_at", self._future())
status, body = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual(2, counter["calls"])
self.assertIn((self.app_uuid, self.token), self.broker.token_cache)
def test_cache_hit_row_absent_is_401(self):
self._auth(self._app_header())
self.broker.store.conn.execute(
"DELETE FROM agent_registrations WHERE agent_id = ?", ("agent-1",)
)
self.broker.store.conn.commit()
status, body = self._auth(self._app_header())
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
self.assertEqual({}, self.broker.token_cache)
def test_scrypt_hash_format_authenticates(self):
token2 = self._register("agent-2")["token"]
original_bcrypt = coreci_broker.bcrypt
coreci_broker.bcrypt = None
try:
scrypt_hash = coreci_broker.hash_token(token2)
finally:
coreci_broker.bcrypt = original_bcrypt
self.assertTrue(scrypt_hash.startswith("scrypt$"))
self._patch_column("agent-2", "token_hash", scrypt_hash)
status, body = self._auth(self._app_header(token=token2))
self.assertEqual(200, status)
self.assertEqual("agent-2", body["agent_id"])
def test_bcrypt_hash_format_authenticates(self):
if coreci_broker.bcrypt is None:
self.skipTest("bcrypt not importable in this environment")
token3 = self._register("agent-3")["token"]
stored = self.broker.store.get_agent("agent-3", self.app_uuid)[4]
self.assertTrue(stored.startswith("$2"))
status, body = self._auth(self._app_header(token=token3))
self.assertEqual(200, status)
self.assertEqual("agent-3", body["agent_id"])
if __name__ == "__main__":
unittest.main()
+347
View File
@@ -0,0 +1,347 @@
import base64
import contextlib
import datetime
import io
import json
import os
import sys
import tempfile
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"
ADMIN_PASS = "correct-horse-battery-staple"
REGISTER_PATH = "/api/broker/agents/register"
APPS_PATH = "/api/broker/app_registrations"
USERS_PATH = "/api/broker/user_allowlist"
WHOAMI_PATH = "/api/broker/auth/whoami"
DEFAULT_TTL = 86400
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
def _seed_direct(broker, app_uuid, user_id):
broker.store.add_row("app_registrations", app_uuid, None)
broker.store.add_row("user_allowlist", user_id, None)
class AgentRegisterTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.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,
}
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot(dict(self.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.app_uuid, self.user_id)
def _request(self, method, path, payload=None, headers=None, base=None):
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
(base or 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:
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 _seed(self, app_uuid, user_id):
status, _ = self._request("POST", APPS_PATH, {"uuid": app_uuid}, self.admin)
self.assertEqual(201, status)
status, _ = self._request("POST", USERS_PATH, {"user_id": user_id}, self.admin)
self.assertEqual(201, status)
def _register(self, agent_id="agent-1", tags=("coreci",), app_uuid=None,
user_id=None, headers=None, payload=None):
if payload is None:
payload = {
"agent_id": agent_id,
"app_registration_uuid": app_uuid or self.app_uuid,
"user_id": user_id or self.user_id,
"tags": list(tags),
}
return self._request("POST", REGISTER_PATH, payload, headers)
def _agent_row(self, agent_id="agent-1", app_uuid=None):
return self.broker.store.get_agent(agent_id, app_uuid or self.app_uuid)
def _audit(self, action=None, result=None):
with open(self.env["BROKER_AUDIT_LOG_PATH"], "r", encoding="utf-8") as handle:
entries = [json.loads(raw) for raw in handle if raw.strip()]
return [
entry for entry in entries
if (action is None or entry["action"] == action)
and (result is None or entry["result"] == result)
]
def _pepper(self):
return self.broker.store.get_config("BROKER_INSTANCE_PEPPER")
def _app_header(self, token, app_uuid=None):
return {"Authorization": "App %s %s" % (app_uuid or self.app_uuid, token)}
def _ttl_delta_seconds(self, body):
expires = datetime.datetime.fromisoformat(body["token_expires_at"])
now = datetime.datetime.now(datetime.timezone.utc)
return (expires - now).total_seconds()
def test_gate_matrix_unknown_and_revoked_uuid_and_user(self):
unknown_uuid = str(uuidlib.uuid4())
status, body = self._register(app_uuid=unknown_uuid)
self.assertEqual(403, status)
self.assertEqual(403, body["error"])
self.assertTrue(body["message"])
self._request("DELETE", APPS_PATH + "/" + self.app_uuid, headers=self.admin)
status, body = self._register()
self.assertEqual(403, status)
self._request("POST", APPS_PATH + "/" + self.app_uuid + "/reinstate", headers=self.admin)
status, body = self._register(user_id="stranger")
self.assertEqual(403, status)
self._request("DELETE", USERS_PATH + "/" + self.user_id, headers=self.admin)
status, body = self._register()
self.assertEqual(403, status)
denied = self._audit("register", "denied")
self.assertEqual(4, len(denied))
for entry in denied:
self.assertEqual(REGISTER_PATH, entry["path"])
self.assertEqual("agent-1", entry["agent_id"])
self.assertEqual(unknown_uuid, denied[0]["app_registration_uuid"])
self.assertEqual(self.user_id, denied[0]["user_id"])
self.assertEqual(self.app_uuid, denied[1]["app_registration_uuid"])
self.assertIsNone(self._agent_row())
self._request("POST", USERS_PATH + "/" + self.user_id + "/reinstate", headers=self.admin)
status, body = self._register()
self.assertEqual(201, status)
self.assertTrue(body["created"])
self.assertIsNotNone(self._agent_row())
def test_malformed_bodies_are_400(self):
base = {"agent_id": "a", "app_registration_uuid": self.app_uuid,
"user_id": self.user_id, "tags": ["x"]}
variants = (
{}, {"agent_id": "a"}, {"agent_id": "a", "app_registration_uuid": self.app_uuid},
dict(base, agent_id=None), dict(base, agent_id=123), dict(base, agent_id=""),
dict(base, app_registration_uuid=None), dict(base, app_registration_uuid=[]),
dict(base, user_id=7), dict(base, user_id=""), dict(base, tags=None),
dict(base, tags="x"), dict(base, tags=["a", 1]), dict(base, tags=[["nested"]]),
dict(base, tags={"a": 1}), [1, 2, 3], "nope",
)
for payload in variants:
status, body = self._request("POST", REGISTER_PATH, payload)
self.assertEqual(400, status, repr(payload))
self.assertEqual(400, body["error"])
self.assertTrue(body["message"])
self.assertIsNone(self._agent_row())
self.assertEqual([], self._audit("register"))
def test_first_register_creates_row_pepper_and_default_ttl(self):
status, body = self._register(agent_id="px-1", tags=("proxmox", "readonly"))
self.assertEqual(201, status)
self.assertTrue(body["created"])
self.assertEqual(self._pepper(), body["pepper"])
expected = coreci_broker.derive_token(
self._pepper(), self.app_uuid, self.user_id, "px-1", ["proxmox", "readonly"]
)
self.assertEqual(expected, body["token"])
self.assertEqual(44, len(body["token"]))
self.assertTrue(body["token"].isascii())
delta = self._ttl_delta_seconds(body)
self.assertGreater(delta, DEFAULT_TTL - 300)
self.assertLess(delta, DEFAULT_TTL + 300)
row = self._agent_row("px-1")
self.assertNotEqual(body["token"], row[4])
self.assertTrue(coreci_broker.verify_token(body["token"], row[4]))
self.assertEqual(self.user_id, row[2])
self.assertEqual(["proxmox", "readonly"], json.loads(row[3]))
self.assertEqual(body["token_expires_at"], row[5])
self.assertTrue(row[6])
self.assertTrue(row[7])
entries = self._audit("register", "ok")
self.assertEqual(1, len(entries))
entry = entries[0]
self.assertEqual(self.user_id, entry["user_id"])
self.assertEqual(self.app_uuid, entry["app_registration_uuid"])
self.assertEqual("px-1", entry["agent_id"])
self.assertEqual(REGISTER_PATH, entry["path"])
def test_reregister_unchanged_reissues_same_token_without_rehash(self):
_, first = self._register()
stored_hash = self._agent_row()[4]
seen_at = self._agent_row()[7]
status, second = self._register()
self.assertEqual(200, status)
self.assertFalse(second["created"])
self.assertEqual(first["token"], second["token"])
self.assertEqual(first["token_expires_at"], second["token_expires_at"])
row = self._agent_row()
self.assertEqual(stored_hash, row[4])
self.assertGreaterEqual(row[7], seen_at)
self.assertTrue(coreci_broker.verify_token(first["token"], row[4]))
self.assertEqual(2, len(self._audit("register", "ok")))
def test_reregister_pepper_rule_tokenless_vs_valid_app_token(self):
_, first = self._register()
status, tokenless = self._register()
self.assertEqual(200, status)
self.assertEqual(first["token"], tokenless["token"])
self.assertIn("pepper", tokenless)
status, with_token = self._register(headers=self._app_header(first["token"]))
self.assertEqual(200, status)
self.assertNotIn("pepper", with_token)
self.assertEqual(first["token"], with_token["token"])
status, bad_token = self._register(
headers={"Authorization": "App %s wrong-token" % self.app_uuid}
)
self.assertEqual(200, status)
self.assertIn("pepper", bad_token)
def test_changed_tags_overwrite_hash_token_and_expiry(self):
_, first = self._register(tags=("red",))
status, second = self._register(tags=("red", "blue"))
self.assertEqual(200, status)
self.assertFalse(second["created"])
self.assertNotEqual(first["token"], second["token"])
row = self._agent_row()
self.assertFalse(coreci_broker.verify_token(first["token"], row[4]))
self.assertTrue(coreci_broker.verify_token(second["token"], row[4]))
self.assertEqual(["blue", "red"], json.loads(row[3]))
self.assertEqual(second["token_expires_at"], row[5])
def test_changed_user_overwrites_row_identity(self):
self._request("POST", USERS_PATH, {"user_id": "coo"}, self.admin)
_, first = self._register()
status, second = self._register(user_id="coo")
self.assertEqual(200, status)
self.assertNotEqual(first["token"], second["token"])
row = self._agent_row()
self.assertEqual("coo", row[2])
self.assertFalse(coreci_broker.verify_token(first["token"], row[4]))
self.assertTrue(coreci_broker.verify_token(second["token"], row[4]))
def test_expired_row_reregister_rehashes_and_extends(self):
_, first = self._register()
row = self._agent_row()
old_hash, first_registered = row[4], row[6]
past = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(seconds=60)).isoformat()
self.broker.store.conn.execute(
"UPDATE agent_registrations SET token_expires_at = ? WHERE agent_id = ?",
(past, "agent-1"),
)
self.broker.store.conn.commit()
status, second = self._register()
self.assertEqual(200, status)
self.assertFalse(second["created"])
self.assertEqual(first["token"], second["token"])
delta = self._ttl_delta_seconds(second)
self.assertGreater(delta, DEFAULT_TTL - 300)
row = self._agent_row()
self.assertNotEqual(old_hash, row[4])
self.assertEqual(first_registered, row[6])
self.assertTrue(coreci_broker.verify_token(first["token"], row[4]))
self.assertEqual(second["token_expires_at"], row[5])
self.assertIn("pepper", second)
def test_revoked_app_blocks_new_registrations_rows_untouched(self):
_, first = self._register()
row_before = self._agent_row()
self._request("DELETE", APPS_PATH + "/" + self.app_uuid, headers=self.admin)
status, body = self._register(agent_id="fresh-agent")
self.assertEqual(403, status)
status, body = self._register()
self.assertEqual(403, status)
self.assertEqual(2, len(self._audit("register", "denied")))
row_after = self._agent_row()
self.assertEqual(row_before[4], row_after[4])
self.assertEqual(row_before[5], row_after[5])
self.assertTrue(coreci_broker.verify_token(first["token"], row_after[4]))
def test_token_ttl_override_honored(self):
env = dict(self.env)
env["BROKER_DB_PATH"] = os.path.join(self.tmp, "ttl.db")
env["BROKER_AUDIT_LOG_PATH"] = os.path.join(self.tmp, "ttl.audit.log")
env["BROKER_TOKEN_TTL"] = "60"
with contextlib.redirect_stdout(io.StringIO()):
broker = coreci_broker.boot(dict(env))
self.addCleanup(broker.audit.close)
self.addCleanup(broker.store.close)
_seed_direct(broker, self.app_uuid, self.user_id)
server = coreci_broker.serve("127.0.0.1", 0, broker)
self.addCleanup(server.shutdown)
self.addCleanup(server.server_close)
base = "http://127.0.0.1:%d" % server.server_address[1]
payload = {"agent_id": "ttl-agent", "app_registration_uuid": self.app_uuid,
"user_id": self.user_id, "tags": []}
status, body = self._request("POST", REGISTER_PATH, payload, base=base)
self.assertEqual(201, status)
delta = self._ttl_delta_seconds(body)
self.assertGreater(delta, 30)
self.assertLess(delta, 90)
status, body = self._request("POST", REGISTER_PATH, payload, base=base)
self.assertEqual(200, status)
delta = self._ttl_delta_seconds(body)
self.assertGreater(delta, 30)
self.assertLess(delta, 90)
def test_user_allowlist_rejects_slash_backslash_and_control_chars(self):
for bad in ("a/b", "a\\b", "a\nb", "a\x00b", "a\x1fb", "a\x7fb"):
status, body = self._request("POST", USERS_PATH, {"user_id": bad}, self.admin)
self.assertEqual(400, status, repr(bad))
self.assertEqual({"error": 400, "message": "invalid user_id"}, body)
status, body = self._request("GET", USERS_PATH, headers=self.admin)
self.assertEqual([self.user_id], [row["user_id"] for row in body["allowlist"]])
def test_bearer_scheme_with_valid_creds_is_401(self):
header = "Bearer " + base64.b64encode(
("%s:%s" % (ADMIN_USER, ADMIN_PASS)).encode("utf-8")
).decode("ascii")
status, body = self._request("GET", WHOAMI_PATH, headers={"Authorization": header})
self.assertEqual(401, status)
self.assertEqual(401, body["error"])
def test_basic_without_colon_is_401(self):
header = "Basic " + base64.b64encode(b"just-a-username").decode("ascii")
status, body = self._request("GET", WHOAMI_PATH, headers={"Authorization": header})
self.assertEqual(401, status)
self.assertEqual(401, body["error"])
def test_basic_garbage_rejected_lowercase_scheme_accepted(self):
status, body = self._request(
"GET", WHOAMI_PATH, headers={"Authorization": "Basic !!!not-base64!!!"}
)
self.assertEqual(401, status)
header = _basic_header(ADMIN_USER, ADMIN_PASS).replace("Basic", "basic")
status, body = self._request("GET", WHOAMI_PATH, headers={"Authorization": header})
self.assertEqual(200, status)
self.assertEqual(ADMIN_USER, body["username"])
if __name__ == "__main__":
unittest.main()
+318
View File
@@ -0,0 +1,318 @@
import base64
import contextlib
import io
import json
import os
import sys
import tempfile
import threading
import unittest
import urllib.error
import urllib.request
import uuid as uuidlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
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"
STATE_PATH = "/api/broker/agents/%s/state"
STUB_PREFIX = "/api/v1/broker-agent-state/"
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
class _StubOwuiHandler(BaseHTTPRequestHandler):
documents = None
records = None
def log_message(self, format, *args):
pass
def do_GET(self):
agent_id = self.path[len(STUB_PREFIX):]
self.records.append(("GET", self.path, self.headers.get("Authorization"), None))
if agent_id not in self.documents:
self._send(404, {"error": 404})
return
self._send(200, self.documents[agent_id])
def do_PUT(self):
agent_id = self.path[len(STUB_PREFIX):]
length = int(self.headers.get("Content-Length") or "0")
raw = self.rfile.read(length)
self.records.append(("PUT", self.path, self.headers.get("Authorization"), raw))
self.documents[agent_id] = json.loads(raw.decode("utf-8"))
self._send(200, {"ok": True})
def _send(self, status, payload):
data = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
class StubOwuiServer:
def __init__(self):
self.documents, self.records = {}, []
handler = type("BoundStubHandler", (_StubOwuiHandler,),
{"documents": self.documents, "records": self.records})
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
@property
def base_url(self):
return "http://127.0.0.1:%d" % self.server.server_address[1]
def close(self):
self.server.shutdown()
self.server.server_close()
class _StateCase(unittest.TestCase):
backend = "internal"
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.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_AGENT_STATE_BACKEND": self.backend,
}
self.env.update(getattr(self, "extra_env", {}))
self._start_broker()
self.admin = {"Authorization": _basic_header(ADMIN_USER, ADMIN_PASS)}
self.app_uuid = str(uuidlib.uuid4())
self.user_id = "ceo"
self._seed(self.app_uuid, self.user_id)
self.registered = self._register("agent-1", ["b", "a"])
self.app = {"Authorization": "App %s %s" % (self.app_uuid, self.registered["token"])}
def _start_broker(self):
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot(dict(self.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]
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 _state(self, method, agent_id, payload=None, headers=None):
return self._request(method, STATE_PATH % agent_id, payload,
self.app if headers is None else headers)
def _seed(self, app_uuid, user_id):
status, _ = self._request("POST", APPS_PATH, {"uuid": app_uuid}, self.admin)
self.assertEqual(201, status)
status, _ = self._request("POST", USERS_PATH, {"user_id": 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
class InternalStateTests(_StateCase):
def test_put_then_get_roundtrip_is_document_faithful(self):
state = {"agent_id": "agent-1", "app_registration_uuid": self.app_uuid,
"user_id": self.user_id, "tags": ["b", "a"],
"pepper": self.registered["pepper"],
"current_token": self.registered["token"],
"token_expires_at": self.registered["token_expires_at"],
"first_registered_at": "t0", "last_seen_at": "t1",
"nested": {"flags": [1, 2.5, None, True], "deep": {"k": None}}, "z": 1}
status, body = self._state("PUT", "agent-1", state)
self.assertEqual(200, status)
self.assertEqual({"ok": True}, body)
status, body = self._state("GET", "agent-1")
self.assertEqual(200, status)
self.assertEqual(state, body)
self.assertEqual(list(state), list(body))
self.assertEqual(state, self.broker.store.get_agent_state("agent-1", self.app_uuid))
def test_state_survives_broker_restart(self):
state = {"counter": 7, "notes": ["x"], "pepper": "keep-me"}
status, _ = self._state("PUT", "agent-1", state)
self.assertEqual(200, status)
self.server.shutdown()
self.server.server_close()
self.broker.store.close()
with contextlib.redirect_stdout(io.StringIO()):
second = coreci_broker.boot(dict(self.env))
self.addCleanup(second.audit.close)
self.addCleanup(second.store.close)
server = coreci_broker.serve("127.0.0.1", 0, second)
self.addCleanup(server.shutdown)
self.addCleanup(server.server_close)
self.base = "http://127.0.0.1:%d" % server.server_address[1]
status, body = self._state("GET", "agent-1")
self.assertEqual(200, status)
self.assertEqual(state, body)
row = second.store.get_agent("agent-1", self.app_uuid)
self.assertTrue(coreci_broker.verify_token(self.registered["token"], row[4]))
def test_identity_mismatch_is_403_on_get_and_put(self):
second = self._register("agent-2", ["coreci"])
for method, payload in (("GET", None), ("PUT", {"who": "agent-2"})):
status, body = self._state(method, "agent-2", payload)
self.assertEqual(403, status, method)
self.assertEqual(403, body["error"])
self.assertTrue(body["message"])
status, body = self._state("GET", "agent-2", headers={
"Authorization": "App %s %s" % (self.app_uuid, second["token"])})
self.assertEqual(404, status)
def test_unregistered_agent_id_is_404_on_get(self):
status, body = self._state("GET", "ghost-agent")
self.assertEqual(404, status)
self.assertEqual({"error": 404, "message": "not found"}, body)
def test_get_without_state_is_404(self):
status, body = self._state("GET", "agent-1")
self.assertEqual(404, status)
self.assertEqual({"error": 404, "message": "not found"}, body)
def test_put_non_dict_is_400(self):
for payload in ([1, 2], "nope", 5, None):
status, body = self._state("PUT", "agent-1", payload)
self.assertEqual(400, status, repr(payload))
self.assertEqual(400, body["error"])
status, _ = self._state("GET", "agent-1")
self.assertEqual(404, status)
def test_put_above_256kib_is_400(self):
status, body = self._state("PUT", "agent-1", {"pad": "x" * (300 * 1024)})
self.assertEqual(400, status)
self.assertEqual(400, body["error"])
status, _ = self._state("GET", "agent-1")
self.assertEqual(404, status)
def test_put_at_exactly_256kib_is_stored(self):
pad = "x" * (256 * 1024 - len(json.dumps({"pad": ""})))
status, body = self._state("PUT", "agent-1", {"pad": pad})
self.assertEqual(200, status)
status, body = self._state("GET", "agent-1")
self.assertEqual(200, status)
self.assertEqual({"pad": pad}, body)
def test_missing_app_header_is_401(self):
status, body = self._state("GET", "agent-1", headers={})
self.assertEqual(401, status)
self.assertEqual({"error": 401, "message": "invalid app credentials"}, body)
class OwuiStateTests(_StateCase):
backend = "owui"
def setUp(self):
self.stub = StubOwuiServer()
self.addCleanup(self.stub.close)
self.extra_env = {
"BROKER_OWUI_BASE_URL": self.stub.base_url,
"BROKER_OWUI_API_KEY": "test-key",
}
super().setUp()
def test_put_then_get_roundtrip_flows_through_stub(self):
state = {"agent_id": "agent-1", "current_token": "tok", "n": 3}
status, body = self._state("PUT", "agent-1", state)
self.assertEqual(200, status)
self.assertEqual({"ok": True}, body)
self.assertEqual(state, self.stub.documents["agent-1"])
status, body = self._state("GET", "agent-1")
self.assertEqual(200, status)
self.assertEqual(state, body)
puts = [record for record in self.stub.records if record[0] == "PUT"]
gets = [record for record in self.stub.records if record[0] == "GET"]
self.assertEqual(1, len(puts))
self.assertEqual(1, len(gets))
for _, path, auth, _ in self.stub.records:
self.assertEqual("Bearer test-key", auth)
self.assertEqual(STUB_PREFIX + "agent-1", puts[0][1])
self.assertEqual(STUB_PREFIX + "agent-1", gets[0][1])
self.assertEqual(state, json.loads(puts[0][3].decode("utf-8")))
def test_stub_miss_maps_to_broker_404(self):
status, body = self._state("GET", "agent-1")
self.assertEqual(404, status)
self.assertEqual({"error": 404, "message": "not found"}, body)
def test_identity_enforced_before_backend_call(self):
self._register("agent-2", ["coreci"])
status, body = self._state("GET", "agent-2")
self.assertEqual(403, status)
status, body = self._state("PUT", "agent-2", {"x": 1})
self.assertEqual(403, status)
self.assertEqual([], self.stub.records)
class BackendToggleTests(unittest.TestCase):
def _boot(self, extra):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
env = {
"BROKER_ENV_PATH": os.path.join(tmp.name, ".env"),
"BROKER_DB_PATH": os.path.join(tmp.name, "broker.db"),
"BROKER_AUDIT_LOG_PATH": os.path.join(tmp.name, "broker.audit.log"),
}
env.update(extra)
with contextlib.redirect_stdout(io.StringIO()):
broker = coreci_broker.boot(env)
self.addCleanup(broker.audit.close)
self.addCleanup(broker.store.close)
return broker
def test_default_backend_is_internal(self):
broker = self._boot({})
self.assertIsInstance(broker.agent_state_backend, coreci_broker.InternalAgentStateBackend)
def test_owui_backend_selected_by_config(self):
broker = self._boot({
"BROKER_AGENT_STATE_BACKEND": "owui",
"BROKER_OWUI_BASE_URL": "http://127.0.0.1:9999/",
"BROKER_OWUI_API_KEY": "k",
})
backend = broker.agent_state_backend
self.assertIsInstance(backend, coreci_broker.OwuiAgentStateBackend)
self.assertEqual("http://127.0.0.1:9999", backend.base_url)
self.assertEqual("k", backend.api_key)
def test_unknown_backend_value_falls_back_to_internal(self):
broker = self._boot({"BROKER_AGENT_STATE_BACKEND": "banana"})
self.assertIsInstance(broker.agent_state_backend, coreci_broker.InternalAgentStateBackend)
if __name__ == "__main__":
unittest.main()