The single-file stdlib server became the limit: no way to track what had been done about an alert, no accounts, and a UI that had to be hand-rolled in template strings. This restructures it into something deployable. Backend (FastAPI) - app/ holds config, database, auth, delivery and the routers; triagelib keeps the triage engine unchanged, so the validated screening and runbook logic is untouched. - Cases persist per alert fingerprint with a status workflow (investigating, customer contacted, escalated to Infra, waiting, remediated, resolved, won't fix, false positive), an assignee, notes and an append-only history. An alert that stops and re-fires lands back on the same case and counts as a reopen. - Suppression rules move from a JSON file into the database. Auth - Signed session cookies over PBKDF2 local accounts, plus an OIDC flow ready for Authentik: users are created on first login and admin follows a group claim. Local login can be switched off entirely once SSO is live. Zendesk and Jira - Delivery is now implemented, behind three gates: the integration must be configured, its feature flag on, and CX_FEATURE_SEND_ENABLED on. A demo instance leaves the last off and cannot mail anyone. Both search before creating, so re-diagnosing an alert updates one ticket rather than opening several, and a rolling daily cap stops a loop mailing everybody. Deployment - Multi-stage Dockerfile builds the bundle and serves it from the API origin. - docker-compose for local and single-host use; Gitea Actions runs the tests, builds the image and renders deploy/k8s with envsubst. Two fixes found while testing: assigning a case returned a null assignee, and add_event could leave an already-loaded history collection stale. Known gap: the engine reaches OpenStack via `docker exec <region>-osc`, which does not work in a pod without the CX-Tools containers alongside it. docs/DEPLOYMENT.md sets out the three ways to close that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
5.6 KiB
Python
125 lines
5.6 KiB
Python
"""API-level tests: auth gates, case lifecycle, suppression rules, send gating.
|
|
|
|
Runs against an in-memory database with CX-Tools stubbed out, so it needs no
|
|
credentials. Prometheus is only touched for cache warming, which is tolerant of
|
|
being unreachable.
|
|
"""
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
os.environ.update({
|
|
"CX_DATABASE_URL": f"sqlite:///{tempfile.mkdtemp()}/test.db",
|
|
"CX_BOOTSTRAP_ADMIN_EMAIL": "admin@localhost",
|
|
"CX_BOOTSTRAP_ADMIN_PASSWORD": "test-password",
|
|
"CX_SECRET_KEY": "test-secret",
|
|
"CX_FEATURE_SEND_ENABLED": "false",
|
|
})
|
|
|
|
from triagelib import cxbridge # noqa: E402
|
|
|
|
cxbridge.bootstrap = lambda: (_ for _ in ()).throw(cxbridge.BridgeError("stubbed"))
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
from app import auth as auth_mod # noqa: E402
|
|
from app.main import app # noqa: E402
|
|
|
|
FAILS = []
|
|
|
|
|
|
def expect(label, cond, got=""):
|
|
print((" PASS " if cond else " FAIL ") + label + ("" if cond else f" <- {got}"))
|
|
if not cond:
|
|
FAILS.append(label)
|
|
|
|
|
|
print("\nPASSWORDS AND SESSIONS")
|
|
h = auth_mod.hash_password("hunter2")
|
|
expect("correct password verifies", auth_mod.verify_password("hunter2", h))
|
|
expect("wrong password rejected", not auth_mod.verify_password("hunter3", h))
|
|
expect("hash is salted (two hashes differ)", auth_mod.hash_password("x") != auth_mod.hash_password("x"))
|
|
token = auth_mod.issue_session(7)
|
|
expect("session round-trips", auth_mod.read_session(token) == 7)
|
|
expect("tampered session rejected", auth_mod.read_session(token[:-4] + "aaaa") is None)
|
|
expect("garbage session rejected", auth_mod.read_session("not-a-token") is None)
|
|
expect("oidc state verifies", auth_mod.oidc_state_valid(auth_mod.oidc_state()))
|
|
expect("forged oidc state rejected", not auth_mod.oidc_state_valid("aaa.bbb"))
|
|
|
|
with TestClient(app) as client:
|
|
print("\nAUTH GATES")
|
|
expect("health is public", client.get("/api/health").status_code == 200)
|
|
expect("alerts need a session", client.get("/api/alerts").status_code == 401)
|
|
expect("cases need a session", client.get("/api/cases").status_code == 401)
|
|
expect("wrong password is 401", client.post(
|
|
"/api/auth/login", json={"email": "admin@localhost", "password": "no"}).status_code == 401)
|
|
|
|
login = client.post("/api/auth/login", json={"email": "admin@localhost", "password": "test-password"})
|
|
expect("login succeeds", login.status_code == 200, login.text[:120])
|
|
expect("bootstrap user is admin", login.json()["user"]["is_admin"])
|
|
expect("session works after login", client.get("/api/cases").status_code == 200)
|
|
|
|
print("\nCONFIG EXPOSURE")
|
|
cfg = client.get("/api/auth/me").json()["config"]
|
|
expect("send disabled by default", cfg["send_enabled"] is False, cfg)
|
|
expect("no secret leaks into public config",
|
|
not any("token" in k.lower() or "secret" in k.lower() for k in cfg), list(cfg))
|
|
|
|
print("\nSUPPRESSION RULES")
|
|
rule = {"name": "Modal ERROR churn", "reason": "known batch churn",
|
|
"conditions": {"kind": ["error"], "organization": ["modal"]}}
|
|
expect("admin can save a rule", client.post("/api/settings/rules", json=rule).status_code == 200)
|
|
expect("rule is persisted", len(client.get("/api/settings").json()["rules"]) == 1)
|
|
expect("a rule with no conditions is refused", client.post(
|
|
"/api/settings/rules", json={"name": "catch all", "conditions": {}}).status_code == 400)
|
|
expect("unknown condition fields are dropped", client.post(
|
|
"/api/settings/rules", json={"name": "bogus", "conditions": {"nope": ["x"]}}).status_code == 400)
|
|
|
|
print("\nSEND GATING")
|
|
status = client.get("/api/actions/status").json()
|
|
expect("send reported as disabled", status["send_enabled"] is False)
|
|
expect("zendesk reported as not ready", status["zendesk_ready"] is False)
|
|
blocked = client.post("/api/actions/zendesk", json={
|
|
"fingerprint": "does-not-exist", "to": "a@b.c", "subject": "s", "body": "b"})
|
|
expect("sending on an untracked alert is refused", blocked.status_code == 404, blocked.text[:120])
|
|
|
|
print("\nCASE LIFECYCLE")
|
|
from app.db import SessionLocal
|
|
from app.models import Case, CaseStatus
|
|
from app.services import add_event, set_status
|
|
|
|
db = SessionLocal()
|
|
case = Case(fingerprint="test-fp", kind="error", title="Instance in ERROR state", subject="vm-1")
|
|
db.add(case)
|
|
db.commit()
|
|
expect("new case starts open", case.is_open and case.status == CaseStatus.NEW)
|
|
|
|
set_status(db, case, CaseStatus.ESCALATED_INFRA, None, "INFRA-1")
|
|
expect("escalated is still open", case.is_open)
|
|
expect("status change is recorded", any(e.action == "status_changed" for e in case.events))
|
|
|
|
set_status(db, case, CaseStatus.RESOLVED, None)
|
|
expect("resolved closes the case", not case.is_open and case.closed_at is not None)
|
|
|
|
add_event(db, case, None, "note", "manual note")
|
|
db.commit()
|
|
expect("history is append-only and ordered newest first",
|
|
case.events[0].action in ("note", "status_changed"), [e.action for e in case.events])
|
|
|
|
payload = case.to_json(with_events=True)
|
|
expect("serialises for the API", payload["status"] == "resolved" and len(payload["events"]) >= 3)
|
|
db.close()
|
|
|
|
expect("bad status is rejected", client.post(
|
|
"/api/cases/test-fp/status", json={"status": "banana"}).status_code == 400)
|
|
expect("unknown case is 404", client.get("/api/cases/nope").status_code == 404)
|
|
|
|
print("\nLOGOUT")
|
|
client.post("/api/auth/logout")
|
|
expect("session is cleared", client.get("/api/cases").status_code == 401)
|
|
|
|
print("\n" + ("ALL CHECKS PASSED" if not FAILS else f"{len(FAILS)} CHECK(S) FAILED: {FAILS}"))
|
|
sys.exit(1 if FAILS else 0)
|