مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
319 lines
12 KiB
Python
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
|