Skip to content

Commit 9ada149

Browse files
committed
Add server-side pagination to /api/alerts endpoints
- Add _parse_pagination() helper: page/per_page query params with validation (page >= 1, 1 <= per_page <= 500, 400 on invalid or non-integer input) - _list_alerts() now returns paginated envelope: {alerts, page, per_page, total, total_pages}; COUNT(*) shares the same WHERE clause so total reflects the filtered set - schema.sql: add idx_alerts_created_at index on alerts(created_at DESC) - dashboard.js: always passes per_page=500 so the full queue is visible; unwraps data.alerts from the envelope in all three loadAlerts() branches - Update existing tests (test_app.py, test_rbac.py) to unwrap ["alerts"] from the paginated response - Add 26 new tests in test_pagination.py covering response shape, mechanics, filter interaction, empty table, and validation for both endpoints - README: update test counts to 142 pytest + 87 Go = 229 total
1 parent 034b170 commit 9ada149

7 files changed

Lines changed: 251 additions & 38 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Security sits on a few specific choices. The ingest endpoint checks its API key
4646
- **pgvector semantic similarity**`POST /api/alerts` stores a fastembed embedding; `GET /api/alerts/<id>/similar` returns the top 5 by cosine distance
4747
- **Kubernetes manifests**`deploy/k8s/` covers Deployment, HPA, Ingress, and Namespace with a separate `Dockerfile.ingest-service` for the Go binary
4848
- **GCP deployment**`deploy/gcp/` (Cloud Run service YAML + Cloud Build pipeline) and `terraform/gcp/` provision the full stack
49-
- 116 pytest + 87 Go = **203 tests** covering the ingest API, auth/CSRF, RBAC, KPI math, encryption, audit trail, Kafka consumer, Redis SSE, pgvector similarity, and the full Go ingest handler and gRPC interceptor surface
49+
- 142 pytest + 87 Go = **229 tests** covering the ingest API, auth/CSRF, RBAC, KPI math, encryption, audit trail, Kafka consumer, Redis SSE, pgvector similarity, pagination, and the full Go ingest handler and gRPC interceptor surface
5050

5151
## Running the Project
5252

@@ -168,7 +168,7 @@ flowchart LR
168168

169169
## Tests
170170

171-
**116 pytest + 87 Go = 203 tests.** The Python suite covers the ingest API, auth/CSRF, RBAC roles, the classify/escalate flow, KPI math (MTTR, SLA, escalation), filter query params, Fernet encryption at rest, audit trail, SSE live updates, Kafka consumer, Redis pub/sub, pgvector semantic similarity, and the seed and user-management CLIs. The Go suite tests the REST and gRPC ingest handlers, `apiKeyInterceptor` boundary conditions (empty key, no metadata, constant-time comparison, whitespace trimming), W3C traceparent parsing edge cases (Python OTel ≥ 1.44 `flags=03`, zero IDs, extra segments, invalid hex), and proto field mapping (`SourceIp→SourceIP`, `WorkflowRunId→WorkflowRunID`). Both suites run against a real PostgreSQL database (Docker on port 5433 for CI). Point `DATABASE_URL` at a throwaway database and run:
171+
**142 pytest + 87 Go = 229 tests.** The Python suite covers the ingest API, auth/CSRF, RBAC roles, the classify/escalate flow, KPI math (MTTR, SLA, escalation), filter query params, server-side pagination, Fernet encryption at rest, audit trail, SSE live updates, Kafka consumer, Redis pub/sub, pgvector semantic similarity, and the seed and user-management CLIs. The Go suite tests the REST and gRPC ingest handlers, `apiKeyInterceptor` boundary conditions (empty key, no metadata, constant-time comparison, whitespace trimming), W3C traceparent parsing edge cases (Python OTel ≥ 1.44 `flags=03`, zero IDs, extra segments, invalid hex), and proto field mapping (`SourceIp→SourceIP`, `WorkflowRunId→WorkflowRunID`). Both suites run against a real PostgreSQL database (Docker on port 5433 for CI). Point `DATABASE_URL` at a throwaway database and run:
172172

173173
```bash
174174
python -m pytest tests/ -v
@@ -184,7 +184,7 @@ python -m pytest tests/ -v
184184
| Semantic search | fastembed embeddings stored in pgvector; `GET /api/alerts/<id>/similar` returns top-5 by cosine distance |
185185
| Horizontal scaling | Redis pub/sub for multi-worker SSE; K8s HPA manifest scales ingest replicas on CPU/RPS |
186186
| Cloud deployment | Cloud Run + Cloud Build (`deploy/gcp/`); Terraform provisions GCP infra (`terraform/gcp/`) |
187-
| Test engineering | 203 tests (116 Python + 87 Go) exercising boundary conditions, constant-time comparisons, W3C traceparent edge cases, and proto field mapping without a live database |
187+
| Test engineering | 229 tests (142 Python + 87 Go) exercising boundary conditions, constant-time comparisons, W3C traceparent edge cases, and proto field mapping without a live database |
188188

189189
## Roles & Permissions
190190

app.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -437,36 +437,79 @@ def analyst():
437437
# --------------------------------------------------------------------------- #
438438
# API
439439
# --------------------------------------------------------------------------- #
440-
def _list_alerts(where_sql, params):
441-
"""Run the shared alert-list query and return decrypted, serialized rows."""
440+
def _parse_pagination():
441+
"""Parse and validate page/per_page from the request query string.
442+
443+
Defaults: page=1, per_page=100. Aborts 400 on non-integer or out-of-range values.
444+
per_page is capped at 500 to prevent unbounded result sets.
445+
"""
446+
try:
447+
page = int(request.args.get("page", 1))
448+
except (ValueError, TypeError):
449+
abort(400, description="page must be a positive integer")
450+
try:
451+
per_page = int(request.args.get("per_page", 100))
452+
except (ValueError, TypeError):
453+
abort(400, description="per_page must be an integer between 1 and 500")
454+
if page < 1:
455+
abort(400, description="page must be >= 1")
456+
if per_page < 1 or per_page > 500:
457+
abort(400, description="per_page must be between 1 and 500")
458+
return page, per_page
459+
460+
461+
def _list_alerts(where_sql, params, page: int, per_page: int) -> dict:
462+
"""Run the shared alert-list query and return a paginated envelope.
463+
464+
The COUNT uses the same WHERE clause so ``total`` reflects the filtered
465+
set, not the whole table.
466+
"""
467+
offset = (page - 1) * per_page
442468
with get_conn() as conn, conn.cursor() as cur:
443469
# nosec B608: where_sql is assembled only from the whitelisted
444470
# FILTER_COLUMNS names; every value is bound as a parameter.
445-
cur.execute("SELECT * FROM alerts" + where_sql + _SEVERITY_ORDER, params) # nosec B608
471+
cur.execute("SELECT count(*) AS c FROM alerts" + where_sql, params) # nosec B608
472+
total = cur.fetchone()["c"]
473+
cur.execute( # nosec B608
474+
"SELECT * FROM alerts" + where_sql + _SEVERITY_ORDER + " LIMIT %s OFFSET %s",
475+
params + [per_page, offset],
476+
)
446477
rows = cur.fetchall()
447-
return [serialize(decrypt_alert(r)) for r in rows]
478+
alerts = [serialize(decrypt_alert(r)) for r in rows]
479+
total_pages = (total + per_page - 1) // per_page if total else 0
480+
return {
481+
"alerts": alerts,
482+
"page": page,
483+
"per_page": per_page,
484+
"total": total,
485+
"total_pages": total_pages,
486+
}
448487

449488

450489
@app.route("/api/alerts")
451490
@login_required
452491
def api_open_alerts():
453492
"""Open queue, optionally filtered by severity/source/assignee.
454493
455-
CRITICAL -> LOW then newest first.
494+
CRITICAL -> LOW then newest first. Paginated: page/per_page params
495+
(default page=1, per_page=100, max per_page=500).
456496
"""
497+
page, per_page = _parse_pagination()
457498
where_sql, params = alert_filters(extra=[("status = %s", "open")])
458-
return jsonify(_list_alerts(where_sql, params))
499+
return jsonify(_list_alerts(where_sql, params, page, per_page))
459500

460501

461502
@app.route("/api/alerts/all")
462503
@login_required
463504
def api_all_alerts():
464505
"""Every alert, optionally filtered by severity/source/assignee.
465506
466-
CRITICAL -> LOW then newest first.
507+
CRITICAL -> LOW then newest first. Paginated: page/per_page params
508+
(default page=1, per_page=100, max per_page=500).
467509
"""
510+
page, per_page = _parse_pagination()
468511
where_sql, params = alert_filters()
469-
return jsonify(_list_alerts(where_sql, params))
512+
return jsonify(_list_alerts(where_sql, params, page, per_page))
470513

471514

472515
def _valid_api_key():

schema.sql

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,12 @@ CREATE TABLE analyst_actions (
5757
response_time_seconds INT
5858
);
5959

60-
CREATE INDEX idx_alerts_status ON alerts(status);
61-
CREATE INDEX idx_alerts_severity ON alerts(severity);
62-
CREATE INDEX idx_alerts_category ON alerts(category);
63-
CREATE INDEX idx_alerts_source ON alerts(source);
64-
CREATE INDEX idx_alerts_assigned ON alerts(assigned_to);
60+
CREATE INDEX idx_alerts_status ON alerts(status);
61+
CREATE INDEX idx_alerts_severity ON alerts(severity);
62+
CREATE INDEX idx_alerts_category ON alerts(category);
63+
CREATE INDEX idx_alerts_source ON alerts(source);
64+
CREATE INDEX idx_alerts_assigned ON alerts(assigned_to);
65+
CREATE INDEX idx_alerts_created_at ON alerts(created_at DESC);
6566
CREATE INDEX idx_actions_alert ON analyst_actions(alert_id);
6667
CREATE INDEX idx_actions_analyst ON analyst_actions(analyst_name);
6768

static/dashboard.js

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ function filterQuery() {
4949
if (f.severity) qs.set("severity", f.severity);
5050
if (f.source) qs.set("source", f.source);
5151
if (f.assigned_to) qs.set("assigned_to", f.assigned_to);
52-
const s = qs.toString();
53-
return s ? `?${s}` : "";
52+
qs.set("per_page", "500");
53+
return `?${qs.toString()}`;
5454
}
5555

5656
// Returns query string for the active preset (merged with filter selects).
@@ -72,8 +72,8 @@ function presetQuery() {
7272
qs.set("created_after", today.toISOString());
7373
}
7474
// "escalated" and "all-open" handled in loadAlerts
75-
const s = qs.toString();
76-
return s ? `?${s}` : "";
75+
qs.set("per_page", "500");
76+
return `?${qs.toString()}`;
7777
}
7878

7979
async function applyPreset(preset) {
@@ -156,14 +156,16 @@ async function loadAlerts() {
156156
let alerts;
157157
if (_activePreset === "escalated") {
158158
const res = await fetch("/api/alerts/all" + filterQuery());
159-
const all = await res.json();
160-
alerts = all.filter(a => a.status === "escalated");
159+
const data = await res.json();
160+
alerts = (data.alerts || []).filter(a => a.status === "escalated");
161161
} else if (_activePreset === "all-open") {
162162
const res = await fetch("/api/alerts" + filterQuery());
163-
alerts = await res.json();
163+
const data = await res.json();
164+
alerts = data.alerts || [];
164165
} else {
165166
const res = await fetch("/api/alerts" + presetQuery());
166-
alerts = await res.json();
167+
const data = await res.json();
168+
alerts = data.alerts || [];
167169
}
168170
const tbody = document.getElementById("alert-rows");
169171
if (!tbody) return;

tests/test_app.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ def test_pages_render(client):
1414

1515

1616
def test_open_alerts_only_and_severity_sorted(client):
17-
rows = client.get("/api/alerts").get_json()
17+
rows = client.get("/api/alerts").get_json()["alerts"]
1818
assert [r["id"] for r in rows] == [4, 3] # HIGH before LOW; only open
1919
assert all(r["status"] == "open" for r in rows)
2020

2121

2222
def test_all_alerts_returns_everything(client):
23-
rows = client.get("/api/alerts/all").get_json()
23+
rows = client.get("/api/alerts/all").get_json()["alerts"]
2424
assert len(rows) == 4
2525

2626

@@ -56,23 +56,23 @@ def test_stats_by_source_and_assignees(client):
5656

5757

5858
def test_filter_alerts_by_severity(client):
59-
rows = client.get("/api/alerts/all?severity=CRITICAL").get_json()
59+
rows = client.get("/api/alerts/all?severity=CRITICAL").get_json()["alerts"]
6060
assert sorted(r["id"] for r in rows) == [1, 2]
6161

6262

6363
def test_filter_alerts_by_source(client):
64-
rows = client.get("/api/alerts/all?source=EDR").get_json()
64+
rows = client.get("/api/alerts/all?source=EDR").get_json()["alerts"]
6565
assert [r["id"] for r in rows] == [2]
6666

6767

6868
def test_filter_alerts_by_assignee(client):
69-
rows = client.get("/api/alerts/all?assigned_to=bob").get_json()
69+
rows = client.get("/api/alerts/all?assigned_to=bob").get_json()["alerts"]
7070
assert [r["id"] for r in rows] == [2]
7171

7272

7373
def test_filter_combines_with_open_queue(client):
7474
# The open queue honors filters too: only open + HIGH -> alert 4.
75-
rows = client.get("/api/alerts?severity=HIGH").get_json()
75+
rows = client.get("/api/alerts?severity=HIGH").get_json()["alerts"]
7676
assert [r["id"] for r in rows] == [4]
7777

7878

@@ -85,7 +85,7 @@ def test_classify_updates_status_and_closes_alert(client):
8585
assert resp.status_code == 200
8686
assert resp.get_json()["status"] == "true_positive"
8787
# alert 4 should no longer appear in the open queue
88-
open_ids = [r["id"] for r in client.get("/api/alerts").get_json()]
88+
open_ids = [r["id"] for r in client.get("/api/alerts").get_json()["alerts"]]
8989
assert 4 not in open_ids
9090

9191

@@ -166,7 +166,7 @@ def test_ingest_alert_creates_open_alert(client):
166166
assert created["workflow_run_id"] == "wf-abc123"
167167
assert "push_ref" in created["run_metadata"]
168168
# the ingested alert is now in the open queue
169-
open_titles = [r["title"] for r in client.get("/api/alerts").get_json()]
169+
open_titles = [r["title"] for r in client.get("/api/alerts").get_json()["alerts"]]
170170
assert payload["title"] in open_titles
171171

172172

@@ -248,7 +248,7 @@ def test_retention_purges_only_old_alerts(client):
248248
# Fixture alert 3 is 2 days old; the rest are <= 1 hour old.
249249
deleted = soc_app.purge_old_alerts(1)
250250
assert deleted == 1
251-
remaining = [r["id"] for r in client.get("/api/alerts/all").get_json()]
251+
remaining = [r["id"] for r in client.get("/api/alerts/all").get_json()["alerts"]]
252252
assert 3 not in remaining
253253
assert sorted(remaining) == [1, 2, 4]
254254

@@ -257,7 +257,7 @@ def test_retention_disabled_is_noop(client):
257257
import app as soc_app
258258

259259
assert soc_app.purge_old_alerts(0) == 0
260-
assert len(client.get("/api/alerts/all").get_json()) == 4
260+
assert len(client.get("/api/alerts/all").get_json()["alerts"]) == 4
261261

262262

263263
def test_sensitive_fields_encrypted_at_rest(client, monkeypatch):

0 commit comments

Comments
 (0)