Compare commits
3 Commits
0.2.4
...
8d47c0c378
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d47c0c378 | |||
| 7f7cf9179f | |||
| 3e317abda8 |
@@ -55,50 +55,6 @@ jobs:
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Trivy scan (backend)
|
||||
uses: aquasecurity/trivy-action@0.24.0
|
||||
with:
|
||||
image-ref: nexapg-backend:dev-scan
|
||||
format: json
|
||||
output: trivy-backend.json
|
||||
severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL
|
||||
ignore-unfixed: false
|
||||
exit-code: 0
|
||||
|
||||
- name: Trivy scan (frontend)
|
||||
uses: aquasecurity/trivy-action@0.24.0
|
||||
with:
|
||||
image-ref: nexapg-frontend:dev-scan
|
||||
format: json
|
||||
output: trivy-frontend.json
|
||||
severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL
|
||||
ignore-unfixed: false
|
||||
exit-code: 0
|
||||
|
||||
- name: Summarize Trivy severities
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from collections import Counter
|
||||
|
||||
def summarize(path):
|
||||
c = Counter()
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
for result in data.get("Results", []):
|
||||
for v in result.get("Vulnerabilities", []) or []:
|
||||
c[v.get("Severity", "UNKNOWN")] += 1
|
||||
for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]:
|
||||
c.setdefault(sev, 0)
|
||||
return c
|
||||
|
||||
for label, path in [("backend", "trivy-backend.json"), ("frontend", "trivy-frontend.json")]:
|
||||
s = summarize(path)
|
||||
print(f"===== Trivy {label} =====")
|
||||
print(f"CRITICAL={s['CRITICAL']} HIGH={s['HIGH']} MEDIUM={s['MEDIUM']} LOW={s['LOW']} UNKNOWN={s['UNKNOWN']}")
|
||||
print()
|
||||
PY
|
||||
|
||||
- name: Docker Scout scan (backend)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
@@ -152,7 +108,5 @@ jobs:
|
||||
with:
|
||||
name: container-cve-scan-reports
|
||||
path: |
|
||||
trivy-backend.json
|
||||
trivy-frontend.json
|
||||
scout-backend.txt
|
||||
scout-frontend.txt
|
||||
|
||||
104
.github/workflows/e2e-api-smoke.yml
vendored
Normal file
104
.github/workflows/e2e-api-smoke.yml
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
name: E2E API Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master", "development"]
|
||||
paths:
|
||||
- "backend/**"
|
||||
- ".github/workflows/e2e-api-smoke.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "backend/**"
|
||||
- ".github/workflows/e2e-api-smoke.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e-smoke:
|
||||
name: Core API E2E Smoke
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: nexapg
|
||||
POSTGRES_USER: nexapg
|
||||
POSTGRES_PASSWORD: nexapg
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U nexapg -d nexapg"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
|
||||
env:
|
||||
APP_NAME: NexaPG Monitor
|
||||
ENVIRONMENT: test
|
||||
LOG_LEVEL: INFO
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_PORT: 5432
|
||||
DB_NAME: nexapg
|
||||
DB_USER: nexapg
|
||||
DB_PASSWORD: nexapg
|
||||
JWT_SECRET_KEY: smoke_jwt_secret_for_ci_only
|
||||
JWT_ALGORITHM: HS256
|
||||
JWT_ACCESS_TOKEN_MINUTES: 15
|
||||
JWT_REFRESH_TOKEN_MINUTES: 10080
|
||||
ENCRYPTION_KEY: 5fLf8HSTbEUeo1c4DnWnvkXxU6v8XJ8iW58wNw5vJ8s=
|
||||
CORS_ORIGINS: http://localhost:5173
|
||||
POLL_INTERVAL_SECONDS: 30
|
||||
INIT_ADMIN_EMAIL: admin@example.com
|
||||
INIT_ADMIN_PASSWORD: ChangeMe123!
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install backend dependencies + test tooling
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r backend/requirements.txt
|
||||
pip install pytest
|
||||
|
||||
- name: Wait for PostgreSQL service
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import asyncio
|
||||
import asyncpg
|
||||
|
||||
async def wait_for_db():
|
||||
dsn = "postgresql://nexapg:nexapg@127.0.0.1:5432/nexapg?sslmode=disable"
|
||||
last_err = None
|
||||
for attempt in range(1, 61):
|
||||
try:
|
||||
conn = await asyncpg.connect(dsn=dsn, timeout=3)
|
||||
try:
|
||||
await conn.execute("SELECT 1")
|
||||
finally:
|
||||
await conn.close()
|
||||
print(f"PostgreSQL ready after {attempt} attempt(s).")
|
||||
return
|
||||
except Exception as exc:
|
||||
last_err = exc
|
||||
await asyncio.sleep(2)
|
||||
raise RuntimeError(f"PostgreSQL not ready after retries: {last_err}")
|
||||
|
||||
asyncio.run(wait_for_db())
|
||||
PY
|
||||
|
||||
- name: Run Alembic migrations
|
||||
working-directory: backend
|
||||
run: alembic upgrade head
|
||||
|
||||
- name: Run core API smoke suite
|
||||
env:
|
||||
PYTHONPATH: backend
|
||||
run: pytest -q backend/tests/e2e/test_api_smoke.py
|
||||
17
README.md
17
README.md
@@ -22,6 +22,7 @@ It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC,
|
||||
- [Reverse Proxy / SSL Guidance](#reverse-proxy--ssl-guidance)
|
||||
- [Production Proxy Profile](#production-proxy-profile)
|
||||
- [PostgreSQL Compatibility Smoke Test](#postgresql-compatibility-smoke-test)
|
||||
- [E2E API Smoke Test](#e2e-api-smoke-test)
|
||||
- [Dependency Exception Flow](#dependency-exception-flow)
|
||||
- [Secret Management (Production)](#secret-management-production)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
@@ -405,6 +406,22 @@ PG_DSN_CANDIDATES='postgresql://postgres:postgres@postgres:5432/compatdb?sslmode
|
||||
python backend/scripts/pg_compat_smoke.py
|
||||
```
|
||||
|
||||
## E2E API Smoke Test
|
||||
|
||||
Core API smoke suite covers:
|
||||
|
||||
- auth login + `/me`
|
||||
- targets CRUD
|
||||
- metrics access
|
||||
- alerts status
|
||||
- admin users CRUD
|
||||
|
||||
Run locally (with backend env vars set and DB migrated):
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend pytest -q backend/tests/e2e/test_api_smoke.py
|
||||
```
|
||||
|
||||
## Dependency Exception Flow
|
||||
|
||||
Python dependency vulnerabilities are enforced by CI via `pip-audit`.
|
||||
|
||||
153
backend/tests/e2e/test_api_smoke.py
Normal file
153
backend/tests/e2e/test_api_smoke.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.main import app
|
||||
from app.models.models import Metric
|
||||
|
||||
|
||||
def _admin_credentials() -> tuple[str, str]:
|
||||
return (
|
||||
os.getenv("INIT_ADMIN_EMAIL", "admin@example.com"),
|
||||
os.getenv("INIT_ADMIN_PASSWORD", "ChangeMe123!"),
|
||||
)
|
||||
|
||||
|
||||
def _auth_headers(access_token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
|
||||
async def _insert_metric(target_id: int, metric_name: str, value: float) -> None:
|
||||
async with SessionLocal() as db:
|
||||
db.add(
|
||||
Metric(
|
||||
target_id=target_id,
|
||||
ts=datetime.now(timezone.utc),
|
||||
metric_name=metric_name,
|
||||
value=value,
|
||||
labels={},
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def test_core_api_smoke_suite() -> None:
|
||||
admin_email, admin_password = _admin_credentials()
|
||||
unique = uuid4().hex[:8]
|
||||
target_name = f"smoke-target-{unique}"
|
||||
user_email = f"smoke-user-{unique}@example.com"
|
||||
|
||||
with TestClient(app) as client:
|
||||
# Auth: login
|
||||
login_res = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": admin_email, "password": admin_password},
|
||||
)
|
||||
assert login_res.status_code == 200, login_res.text
|
||||
tokens = login_res.json()
|
||||
assert tokens.get("access_token")
|
||||
assert tokens.get("refresh_token")
|
||||
headers = _auth_headers(tokens["access_token"])
|
||||
|
||||
# Auth: me
|
||||
me_res = client.get("/api/v1/me", headers=headers)
|
||||
assert me_res.status_code == 200, me_res.text
|
||||
assert me_res.json()["email"] == admin_email
|
||||
|
||||
# Targets: create
|
||||
create_target_res = client.post(
|
||||
"/api/v1/targets",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": target_name,
|
||||
"host": "127.0.0.1",
|
||||
"port": 5432,
|
||||
"dbname": "postgres",
|
||||
"username": "postgres",
|
||||
"password": "postgres",
|
||||
"sslmode": "disable",
|
||||
"use_pg_stat_statements": False,
|
||||
"owner_user_ids": [],
|
||||
"tags": {"suite": "e2e-smoke"},
|
||||
},
|
||||
)
|
||||
assert create_target_res.status_code == 201, create_target_res.text
|
||||
target = create_target_res.json()
|
||||
target_id = target["id"]
|
||||
|
||||
# Targets: list/get/update
|
||||
list_targets_res = client.get("/api/v1/targets", headers=headers)
|
||||
assert list_targets_res.status_code == 200, list_targets_res.text
|
||||
assert any(item["id"] == target_id for item in list_targets_res.json())
|
||||
|
||||
get_target_res = client.get(f"/api/v1/targets/{target_id}", headers=headers)
|
||||
assert get_target_res.status_code == 200, get_target_res.text
|
||||
|
||||
update_target_res = client.put(
|
||||
f"/api/v1/targets/{target_id}",
|
||||
headers=headers,
|
||||
json={"name": f"{target_name}-updated"},
|
||||
)
|
||||
assert update_target_res.status_code == 200, update_target_res.text
|
||||
assert update_target_res.json()["name"].endswith("-updated")
|
||||
|
||||
# Metrics access
|
||||
asyncio.run(_insert_metric(target_id, "connections_total", 7.0))
|
||||
now = datetime.now(timezone.utc)
|
||||
from_ts = (now - timedelta(minutes=5)).isoformat()
|
||||
to_ts = (now + timedelta(minutes=5)).isoformat()
|
||||
metrics_res = client.get(
|
||||
f"/api/v1/targets/{target_id}/metrics",
|
||||
headers=headers,
|
||||
params={"metric": "connections_total", "from": from_ts, "to": to_ts},
|
||||
)
|
||||
assert metrics_res.status_code == 200, metrics_res.text
|
||||
assert isinstance(metrics_res.json(), list)
|
||||
assert len(metrics_res.json()) >= 1
|
||||
|
||||
# Alerts status
|
||||
alerts_status_res = client.get("/api/v1/alerts/status", headers=headers)
|
||||
assert alerts_status_res.status_code == 200, alerts_status_res.text
|
||||
payload = alerts_status_res.json()
|
||||
assert "warnings" in payload
|
||||
assert "alerts" in payload
|
||||
|
||||
# Admin users: list/create/update/delete
|
||||
users_res = client.get("/api/v1/admin/users", headers=headers)
|
||||
assert users_res.status_code == 200, users_res.text
|
||||
assert isinstance(users_res.json(), list)
|
||||
|
||||
create_user_res = client.post(
|
||||
"/api/v1/admin/users",
|
||||
headers=headers,
|
||||
json={
|
||||
"email": user_email,
|
||||
"first_name": "Smoke",
|
||||
"last_name": "User",
|
||||
"password": "SmokePass123!",
|
||||
"role": "viewer",
|
||||
},
|
||||
)
|
||||
assert create_user_res.status_code == 201, create_user_res.text
|
||||
created_user_id = create_user_res.json()["id"]
|
||||
|
||||
update_user_res = client.put(
|
||||
f"/api/v1/admin/users/{created_user_id}",
|
||||
headers=headers,
|
||||
json={"role": "operator", "first_name": "SmokeUpdated"},
|
||||
)
|
||||
assert update_user_res.status_code == 200, update_user_res.text
|
||||
assert update_user_res.json()["role"] == "operator"
|
||||
|
||||
delete_user_res = client.delete(f"/api/v1/admin/users/{created_user_id}", headers=headers)
|
||||
assert delete_user_res.status_code == 200, delete_user_res.text
|
||||
assert delete_user_res.json().get("status") == "deleted"
|
||||
|
||||
# Cleanup target
|
||||
delete_target_res = client.delete(f"/api/v1/targets/{target_id}", headers=headers)
|
||||
assert delete_target_res.status_code == 200, delete_target_res.text
|
||||
assert delete_target_res.json().get("status") == "deleted"
|
||||
Reference in New Issue
Block a user