Split into a FastAPI backend and a React frontend, add case state and SSO
Some checks failed
build-and-deploy / test (push) Has been cancelled
build-and-deploy / image (push) Has been cancelled
build-and-deploy / deploy (push) Has been cancelled

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>
This commit is contained in:
2026-08-06 07:11:28 +01:00
parent a039e0b5fd
commit 1262690276
68 changed files with 3839 additions and 2223 deletions

124
backend/tests/test_api.py Normal file
View File

@@ -0,0 +1,124 @@
"""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)