Files
smart-org/apps/api/tests/test_error_console.py
mjalmoudy94 717e53a7ce نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
2026-10-13 00:39:28 +03:30

319 lines
12 KiB
Python

"""The error console API — platform-owner only, grouped, and audited.
The console is the reason the error table exists at all: an error nobody can
read is not observability, it is a write-only table. These tests cover both
halves — that a regular customer can never reach it, and that the platform owner
can actually triage with it.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
import pytest_asyncio
from httpx import AsyncClient
from app.domain.audit.models import ErrorSeverity, ErrorSource
from app.infrastructure.error_log import record_error
from tests.conftest import auth_header, signup
CONSOLE = "/api/admin/errors"
@pytest_asyncio.fixture(autouse=True)
async def _isolated_error_log(redirect_error_log) -> AsyncIterator[None]:
yield
async def _seed_error(
*,
kind: str = "unhandled_exception",
message: str = "boom",
error_type: str = "RuntimeError",
severity: str = ErrorSeverity.ERROR,
source: str = ErrorSource.HTTP,
path: str = "/api/organizations/{id}/projects",
organization_id: str | None = None,
method: str = "GET",
status_code: int | None = 500,
) -> str | None:
"""Write one error row through the real recorder, so fingerprints are real."""
exc: BaseException | None = None
if error_type:
exc = type(error_type, (Exception,), {})(message)
event_id = await record_error(
kind=kind,
message=message,
exc=exc,
severity=severity,
source=source,
path=path,
method=method,
status_code=status_code,
organization_id=organization_id,
)
assert event_id is not None, "the recorder failed to write a row"
return event_id
# ---------------------------------------------------------------------------
# Access control
# ---------------------------------------------------------------------------
async def test_a_regular_customer_cannot_reach_the_console(client: AsyncClient):
"""The console exposes every tenant's failures; it is platform-owner only."""
token = (await signup(client, "nosy@example.com"))["access_token"]
for path in (
CONSOLE,
f"{CONSOLE}/summary",
f"{CONSOLE}/groups",
f"{CONSOLE}/some-id",
):
response = await client.get(path, headers=auth_header(token))
assert response.status_code == 403, f"{path} leaked to a customer"
async def test_an_anonymous_caller_cannot_reach_the_console(client: AsyncClient):
response = await client.get(CONSOLE)
assert response.status_code == 401
async def test_resolving_requires_a_platform_owner(client: AsyncClient):
event_id = await _seed_error()
token = (await signup(client, "not-owner@example.com"))["access_token"]
response = await client.post(
f"{CONSOLE}/{event_id}/resolve", json={"note": "nope"}, headers=auth_header(token)
)
assert response.status_code == 403
# ---------------------------------------------------------------------------
# Listing
# ---------------------------------------------------------------------------
async def test_errors_are_listed_newest_first(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(message="first failure")
await _seed_error(message="second failure")
response = await client.get(CONSOLE, headers=auth_header(platform_owner_token))
assert response.status_code == 200, response.text
body = response.json()
assert body["total"] == 2
messages = [item["message"] for item in body["items"]]
assert messages == ["second failure", "first failure"]
async def test_the_list_carries_everything_needed_to_diagnose(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(
kind="task_run_failed",
message="provider returned garbage",
error_type="ValueError",
status_code=None,
method="",
path="",
)
item = (
await client.get(CONSOLE, headers=auth_header(platform_owner_token))
).json()["items"][0]
for field in (
"id", "kind", "message", "stack", "severity", "source", "error_type",
"fingerprint", "status_code", "method", "path", "organization_id",
"user_id", "request_id", "ip", "user_agent", "metadata_json",
"resolved_at", "resolved_note", "created_at",
):
assert field in item, f"the console row is missing {field}"
assert item["kind"] == "task_run_failed"
assert item["error_type"] == "ValueError"
async def test_filters_narrow_the_list(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(message="http thing", source=ErrorSource.HTTP)
await _seed_error(message="worker thing", source=ErrorSource.WORKER, method="", path="")
headers = auth_header(platform_owner_token)
by_source = await client.get(f"{CONSOLE}?source=worker", headers=headers)
assert [i["message"] for i in by_source.json()["items"]] == ["worker thing"]
by_severity = await client.get(f"{CONSOLE}?severity=error", headers=headers)
assert by_severity.json()["total"] == 2
by_search = await client.get(f"{CONSOLE}?search=worker", headers=headers)
assert [i["message"] for i in by_search.json()["items"]] == ["worker thing"]
async def test_the_page_size_is_capped(client: AsyncClient, platform_owner_token: str):
"""An uncapped limit would let one request pull every stack trace at once."""
response = await client.get(
f"{CONSOLE}?limit=100000", headers=auth_header(platform_owner_token)
)
assert response.status_code == 422
async def test_unknown_error_id_is_a_404(client: AsyncClient, platform_owner_token: str):
response = await client.get(f"{CONSOLE}/nope", headers=auth_header(platform_owner_token))
assert response.status_code == 404
assert response.json()["error"]["code"] == "not_found"
async def test_summary_counts_open_failures_by_severity(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(severity=ErrorSeverity.CRITICAL)
await _seed_error(severity=ErrorSeverity.ERROR)
await _seed_error(severity=ErrorSeverity.WARNING)
body = (
await client.get(f"{CONSOLE}/summary", headers=auth_header(platform_owner_token))
).json()
assert body["total_open"] == 3
assert body["open_by_severity"] == {"critical": 1, "error": 1, "warning": 1}
# ---------------------------------------------------------------------------
# Grouping — the view that makes the console usable
# ---------------------------------------------------------------------------
async def test_identical_failures_collapse_into_one_group(
client: AsyncClient, platform_owner_token: str
):
"""A bug that fired 4 000 times must be one line with a count, not 4 000."""
for entity in ("aaaa1111bbbb2222cccc3333dddd4444", "9999eeee8888ffff7777aaaa6666bbbb"):
await _seed_error(
message="task not found while starting a run",
path=f"/api/organizations/{entity}/projects",
)
await _seed_error(message="a completely different failure", path="/api/other")
groups = (
await client.get(f"{CONSOLE}/groups", headers=auth_header(platform_owner_token))
).json()
assert len(groups) == 2
biggest = max(groups, key=lambda g: g["occurrences"])
assert biggest["occurrences"] == 2
assert biggest["open_count"] == 2
assert biggest["sample_message"] == "task not found while starting a run"
assert biggest["first_seen"] and biggest["last_seen"]
async def test_group_listing_is_reachable_before_the_id_route(
client: AsyncClient, platform_owner_token: str
):
""""groups" must not be swallowed by ``/errors/{error_id}``."""
response = await client.get(f"{CONSOLE}/groups", headers=auth_header(platform_owner_token))
assert response.status_code == 200
assert isinstance(response.json(), list)
# ---------------------------------------------------------------------------
# Triage
# ---------------------------------------------------------------------------
async def test_resolving_a_group_closes_every_open_occurrence(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(message="the same bug", path="/api/x")
await _seed_error(message="the same bug", path="/api/x")
headers = auth_header(platform_owner_token)
fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"]
resolved = await client.post(
f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": "fixed in v2"}, headers=headers
)
assert resolved.status_code == 200, resolved.text
assert resolved.json()["resolved"] == 2
# Nothing is left open…
assert (await client.get(f"{CONSOLE}?status=open", headers=headers)).json()["total"] == 0
# …and the note is on the records.
closed = (await client.get(f"{CONSOLE}?status=resolved", headers=headers)).json()["items"]
assert all(item["resolved_note"] == "fixed in v2" for item in closed)
assert all(item["resolved_at"] for item in closed)
async def test_resolving_a_group_is_audited(
client: AsyncClient, platform_owner_token: str, session_factory
):
""""Who decided this bug was fixed?" must have an answer."""
from sqlalchemy import select
from app.infrastructure.db.models import AuditEventRow
await _seed_error(message="auditable bug", path="/api/y")
headers = auth_header(platform_owner_token)
fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"]
await client.post(
f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": "done"}, headers=headers
)
async with session_factory() as session:
entries = list(
(
await session.execute(
select(AuditEventRow).where(AuditEventRow.action == "error.resolve")
)
).scalars()
)
assert len(entries) == 1, "triaging an error must leave a trail"
assert entries[0].target_id == fingerprint
assert entries[0].actor_user_id
assert entries[0].metadata_json["resolved"] == 1
assert entries[0].metadata_json["note"] == "done"
async def test_reopening_a_group_brings_the_bug_back(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(message="flaky bug", path="/api/z")
headers = auth_header(platform_owner_token)
fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"]
await client.post(
f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": "ok"}, headers=headers
)
reopened = await client.post(f"{CONSOLE}/groups/{fingerprint}/reopen", headers=headers)
assert reopened.json()["resolved"] == 1
assert (await client.get(f"{CONSOLE}?status=open", headers=headers)).json()["total"] == 1
async def test_resolving_an_already_closed_group_is_a_404(
client: AsyncClient, platform_owner_token: str
):
"""Rather than silently reporting success on a no-op."""
await _seed_error(message="close me twice", path="/api/w")
headers = auth_header(platform_owner_token)
fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"]
await client.post(f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": ""}, headers=headers)
second = await client.post(
f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": ""}, headers=headers
)
assert second.status_code == 404
async def test_the_open_filter_hides_resolved_groups(
client: AsyncClient, platform_owner_token: str
):
await _seed_error(message="will be closed", path="/api/closed")
headers = auth_header(platform_owner_token)
fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"]
await client.post(f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": ""}, headers=headers)
assert (await client.get(f"{CONSOLE}/groups?status=open", headers=headers)).json() == []
resolved = (await client.get(f"{CONSOLE}/groups?status=resolved", headers=headers)).json()
assert len(resolved) == 1
assert resolved[0]["open_count"] == 0