feat: Problems correlation fingerprint, count, last_seen (d1-2)
- Migration 003; windowed host+type+rule correlation on ingest
- GET /problems filters; GET /problems/{id} with event timeline; ack/resolve 409 guard
- Tests and SAC_PROBLEM_CORRELATION_WINDOW_MINUTES config
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,6 +14,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.auth.api_key import hash_api_key
|
||||
from app.auth.jwt_auth import create_access_token
|
||||
from app.database import Base, get_db
|
||||
from app.main import app as fastapi_app
|
||||
import app.models # noqa: F401 — register all tables on Base.metadata
|
||||
@@ -80,3 +81,9 @@ def client(db_session, monkeypatch):
|
||||
@pytest.fixture
|
||||
def auth_headers():
|
||||
return {"Authorization": f"Bearer {TEST_API_KEY}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jwt_headers():
|
||||
token = create_access_token("test-admin")
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Problems correlation and API smoke tests."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from tests.test_ingest import VALID_EVENT
|
||||
|
||||
|
||||
def _event_payload(**overrides):
|
||||
now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
base = {
|
||||
**VALID_EVENT,
|
||||
"event_id": str(uuid.uuid4()),
|
||||
"occurred_at": now,
|
||||
"severity": "high",
|
||||
"type": "ssh.ip.banned",
|
||||
"title": "Ban test",
|
||||
"summary": "test problem correlation",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_problem_correlates_two_events(client, auth_headers, jwt_headers):
|
||||
e1 = _event_payload()
|
||||
e2 = _event_payload(title="Ban test 2")
|
||||
assert client.post("/api/v1/events", json=e1, headers=auth_headers).status_code == 201
|
||||
assert client.post("/api/v1/events", json=e2, headers=auth_headers).status_code == 201
|
||||
|
||||
r = client.get("/api/v1/problems?status=open", headers=jwt_headers)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["total"] == 1
|
||||
problem = data["items"][0]
|
||||
assert problem["event_count"] == 2
|
||||
assert problem["fingerprint"].startswith("h")
|
||||
assert ":tssh.ip.banned:" in problem["fingerprint"]
|
||||
|
||||
detail = client.get(f"/api/v1/problems/{problem['id']}", headers=jwt_headers)
|
||||
assert detail.status_code == 200
|
||||
assert len(detail.json()["events"]) == 2
|
||||
|
||||
|
||||
def test_problem_ack_and_resolve(client, auth_headers, jwt_headers):
|
||||
payload = _event_payload()
|
||||
client.post("/api/v1/events", json=payload, headers=auth_headers)
|
||||
pid = client.get("/api/v1/problems?status=open", headers=jwt_headers).json()["items"][0]["id"]
|
||||
|
||||
ack = client.post(f"/api/v1/problems/{pid}/ack", headers=jwt_headers)
|
||||
assert ack.status_code == 200
|
||||
assert ack.json()["status"] == "acknowledged"
|
||||
|
||||
res = client.post(f"/api/v1/problems/{pid}/resolve", headers=jwt_headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["status"] == "resolved"
|
||||
|
||||
|
||||
def test_new_problem_after_resolve(client, auth_headers, jwt_headers):
|
||||
p1 = _event_payload()
|
||||
client.post("/api/v1/events", json=p1, headers=auth_headers)
|
||||
pid = client.get("/api/v1/problems?status=open", headers=jwt_headers).json()["items"][0]["id"]
|
||||
client.post(f"/api/v1/problems/{pid}/resolve", headers=jwt_headers)
|
||||
|
||||
p2 = _event_payload(summary="after resolve")
|
||||
client.post("/api/v1/events", json=p2, headers=auth_headers)
|
||||
open_items = client.get("/api/v1/problems?status=open", headers=jwt_headers).json()["items"]
|
||||
assert len(open_items) == 1
|
||||
assert open_items[0]["event_count"] == 1
|
||||
assert open_items[0]["id"] != pid
|
||||
Reference in New Issue
Block a user