v1.1.0: Alembic, pytest, домен и документация

- Миграции PostgreSQL через Alembic; DDL убран из lifespan приложения.
- Тесты: health, status, ingress Grafana; моки Vault/Grafana/Forgejo.
- Пакет onguard24/domain/ (сущности, шина событий), docs/DOMAIN.md.
- Обновлены README, CHANGELOG, ARCHITECTURE.

Made-with: Cursor
This commit is contained in:
Alexandr
2026-04-03 08:36:35 +03:00
parent 4da9b13a86
commit 85eb61b576
21 changed files with 611 additions and 32 deletions

39
tests/conftest.py Normal file
View File

@ -0,0 +1,39 @@
"""Изоляция тестов от локального .env: секреты сбрасываются до импорта приложения."""
from __future__ import annotations
import os
# Не ходим в реальные Vault/Grafana/Forgejo/Postgres при прогоне тестов
for key in (
"DATABASE_URL",
"VAULT_ADDR",
"VAULT_TOKEN",
"GRAFANA_URL",
"GRAFANA_SERVICE_ACCOUNT_TOKEN",
"FORGEJO_URL",
"FORGEJO_TOKEN",
"GRAFANA_WEBHOOK_SECRET",
):
os.environ.pop(key, None)
os.environ["DATABASE_URL"] = ""
os.environ["VAULT_ADDR"] = ""
os.environ["GRAFANA_URL"] = ""
os.environ["FORGEJO_URL"] = ""
import pytest
from fastapi.testclient import TestClient
from onguard24.main import app
def pytest_configure() -> None:
"""Дополнительно: гарантировать пустые интеграции."""
os.environ.setdefault("DATABASE_URL", "")
@pytest.fixture
def client() -> TestClient:
"""Контекстный менеджер — отрабатывает lifespan (pool, settings в state)."""
with TestClient(app) as c:
yield c

22
tests/test_domain.py Normal file
View File

@ -0,0 +1,22 @@
import pytest
from onguard24.domain import Alert, AlertReceived, InMemoryEventBus, Severity
def test_alert_model() -> None:
a = Alert(source="grafana", severity=Severity.CRITICAL, title="x")
assert a.source == "grafana"
@pytest.mark.asyncio
async def test_event_bus_alert_received() -> None:
seen: list[str] = []
async def h(ev: AlertReceived) -> None:
seen.append(ev.name)
bus = InMemoryEventBus()
bus.subscribe("alert.received", h) # type: ignore[arg-type]
a = Alert(source="grafana")
await bus.publish_alert_received(a)
assert seen == ["alert.received"]

13
tests/test_health.py Normal file
View File

@ -0,0 +1,13 @@
from fastapi.testclient import TestClient
def test_health(client: TestClient) -> None:
r = client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
assert r.json()["service"] == "onGuard24"
def test_health_api_v1(client: TestClient) -> None:
r = client.get("/api/v1/health")
assert r.status_code == 200

60
tests/test_ingress.py Normal file
View File

@ -0,0 +1,60 @@
import json
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi.testclient import TestClient
def test_grafana_webhook_no_db(client: TestClient) -> None:
"""Без пула БД — 202, запись не падает."""
r = client.post(
"/api/v1/ingress/grafana",
content=json.dumps({"title": "t"}),
headers={"Content-Type": "application/json"},
)
assert r.status_code == 202
def test_grafana_webhook_unauthorized_when_secret_set(client: TestClient) -> None:
app = client.app
real = app.state.settings.grafana_webhook_secret
app.state.settings.grafana_webhook_secret = "s3cr3t"
try:
r = client.post(
"/api/v1/ingress/grafana",
content=b"{}",
headers={"Content-Type": "application/json"},
)
assert r.status_code == 401
r2 = client.post(
"/api/v1/ingress/grafana",
content=b"{}",
headers={"Content-Type": "application/json", "X-OnGuard-Secret": "s3cr3t"},
)
assert r2.status_code == 202
finally:
app.state.settings.grafana_webhook_secret = real
def test_grafana_webhook_inserts_with_mock_pool(client: TestClient) -> None:
mock_conn = AsyncMock()
mock_conn.execute = AsyncMock()
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
mock_cm.__aexit__ = AsyncMock(return_value=None)
mock_pool = MagicMock()
mock_pool.acquire = MagicMock(return_value=mock_cm)
app = client.app
real_pool = app.state.pool
app.state.pool = mock_pool
try:
r = client.post(
"/api/v1/ingress/grafana",
content=json.dumps({"a": 1}),
headers={"Content-Type": "application/json"},
)
assert r.status_code == 202
mock_conn.execute.assert_called_once()
finally:
app.state.pool = real_pool

61
tests/test_status.py Normal file
View File

@ -0,0 +1,61 @@
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
def test_status_without_integrations(client: TestClient) -> None:
"""Без БД и без URL внешних сервисов — всё disabled."""
r = client.get("/api/v1/status")
assert r.status_code == 200
data = r.json()
assert data["service"] == "onGuard24"
assert data["database"] == "disabled"
assert data["vault"] == "disabled"
assert data["grafana"] == "disabled"
assert data["forgejo"] == "disabled"
def test_status_with_mocks(client: TestClient) -> None:
"""Моки внешних вызовов — ok-ветки без сети."""
with (
patch("onguard24.status_snapshot.vault_ping", new_callable=AsyncMock) as vp,
patch("onguard24.status_snapshot.grafana_api.ping", new_callable=AsyncMock) as gp,
patch(
"onguard24.status_snapshot.grafana_api.get_signed_in_user",
new_callable=AsyncMock,
) as gu,
patch("onguard24.status_snapshot.forgejo_api.probe", new_callable=AsyncMock) as fp,
):
vp.return_value = (True, None)
gp.return_value = (True, None)
gu.return_value = ({"login": "tester", "email": "t@x"}, None)
fp.return_value = {"status": "ok", "url": "https://x", "api": "authenticated", "login": "u"}
# Подмена полей settings (pydantic-settings иначе тянет env поверх конструктора)
from types import SimpleNamespace
app = client.app
real = app.state.settings
app.state.settings = SimpleNamespace(
database_url="",
vault_addr="https://vault.example",
vault_token="t",
grafana_url="https://grafana.example",
grafana_service_account_token="g",
forgejo_url="https://git.example",
forgejo_token="f",
grafana_webhook_secret="",
http_addr="0.0.0.0:8080",
log_level="info",
)
try:
r = client.get("/api/v1/status")
finally:
app.state.settings = real
assert r.status_code == 200
d = r.json()
assert d["vault"]["status"] == "ok"
assert d["grafana"]["status"] == "ok"
assert d["grafana"].get("service_account_login") == "tester"
assert d["forgejo"]["status"] == "ok"