feat(diagnostics): send a bug report to a private Gitea issue tracker (E16 S4)

- admin config group "Bug reports": Gitea address, repository, token (secret),
  and "Check the connection" — the repository must answer, take issues and
  be PRIVATE (a report carries its reporter's log); checked on every send too
- POST /api/debug-sessions/{id}/issue sends the SAVED draft: create the issue,
  upload each file on its own request (the measured API), then show the
  screenshots inline; progress is kept in the draft, so a send that stops
  half-way keeps its issue and the retry sends only what is missing; 5 new
  issues an hour per user; audited as issue_create
- the composer: Create issue (only where a tracker is set up, naming the
  repository), a partial-send banner with retry, and a Preview — the tracker's
  own rendering in a sandboxed frame, or the zip's markdown without one
- the recorder's refusals answer in the localized family (debug_session_*,
  screenshot_*, issue(s)_*), not bare codes the UI could only call generic
- a tracker refusal logs the status only: its message can quote the title back
This commit is contained in:
2026-09-19 22:48:20 +02:00
parent a5fae489e0
commit 9cf177b198
26 changed files with 883 additions and 32 deletions
+208 -6
View File
@@ -10,15 +10,17 @@ Four parts:
* screenshots: judged by their bytes, capped in size and count, served back only to their owner.
"""
import json
import logging
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from fastapi import BackgroundTasks
from sqlalchemy import select
from app import debugsession, logs
from app.models import DebugScreenshot, DebugSession, LogEvent, User
from app.models import AuditLog, DebugScreenshot, DebugSession, LogEvent, User
from app.routes import diagnostics as diagnostics_routes
_PW = "correct horse battery"
@@ -406,13 +408,14 @@ def test_a_screenshot_is_kept_as_what_its_bytes_are(recording, data, mime):
@pytest.mark.parametrize(
("data", "reason"), [(b"<svg onload=alert(1)>", "not_an_image"), (b"", "empty")]
("data", "reason"),
[(b"<svg onload=alert(1)>", "screenshot_not_an_image"), (b"", "screenshot_empty")],
)
def test_what_is_not_an_image_is_refused(recording, data, reason):
client, sid = recording
r = _upload(client, sid, data)
assert r.status_code == 422
assert r.json()["detail"] == {"code": "screenshot_refused", "reason": reason}
assert r.json()["detail"] == {"code": "refused", "reason": reason}
def test_screenshots_are_capped_in_size_and_count(recording, monkeypatch):
@@ -422,7 +425,11 @@ def test_screenshots_are_capped_in_size_and_count(recording, monkeypatch):
for _ in range(debugsession.SCREENSHOTS_MAX):
assert _upload(client, sid, _PNG).status_code == 200
r = _upload(client, sid, _PNG)
assert r.status_code == 422 and r.json()["detail"]["reason"] == "too_many"
assert r.status_code == 422 and r.json()["detail"] == {
"code": "refused",
"reason": "screenshot_too_many",
"limit": debugsession.SCREENSHOTS_MAX,
}
assert (
client.get(f"/api/debug-sessions/{sid}").json()["screenshots"]
== debugsession.SCREENSHOTS_MAX
@@ -434,7 +441,7 @@ def test_a_sent_session_takes_no_more_screenshots(db, recording):
db.get(DebugSession, sid).issue_url = "https://git.example/o/r/issues/1"
db.commit()
r = _upload(client, sid, _PNG)
assert r.status_code == 422 and r.json()["detail"]["reason"] == "already_sent"
assert r.status_code == 409 and r.json()["detail"]["reason"] == "screenshot_already_sent"
def test_a_screenshot_can_be_removed_and_is_then_gone(recording):
@@ -486,7 +493,7 @@ def test_a_sent_session_keeps_its_draft(db, recording):
db.get(DebugSession, sid).issue_url = "https://git.example/o/r/issues/1"
db.commit()
r = client.put(f"/api/debug-sessions/{sid}/draft", json={"title": "t", "body": "b"})
assert r.status_code == 409 and r.json()["detail"]["code"] == "already_sent"
assert r.status_code == 409 and r.json()["detail"]["reason"] == "issue_already_sent"
def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make_user, as_user):
@@ -543,3 +550,198 @@ def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make
assert "kept click" in web and "private search" not in web
assert "GET /api/x 200" in api and all(m != "kept click" for m in api)
assert z.read("screenshot-1.png") == _PNG
# --- the issue tracker (S4) -----------------------------------------------------------------
class FakeGitea:
"""Gitea 1.27 as measured (plans/e16 S0): a private repository, one file per asset upload, and
a way to make one upload fail."""
def __init__(self):
self.calls: list[tuple[str, str]] = []
self.private = True
self.has_issues = True
self.token_ok = True
self.fail_asset: str | None = None
self.patched_body: str | None = None
def handler(self, request: httpx.Request) -> httpx.Response:
path = request.url.path.removeprefix("/api/v1")
self.calls.append((request.method, path))
if request.headers.get("authorization") != "token t0ken" or not self.token_ok:
return httpx.Response(401, json={"message": "invalid username, password or token"})
if request.method == "GET" and path == "/repos/o/r":
return httpx.Response(
200,
json={
"full_name": "o/r",
"private": self.private,
"has_issues": self.has_issues,
"html_url": "https://git.example/o/r",
},
)
if request.method == "POST" and path == "/repos/o/r/issues":
return httpx.Response(
201, json={"number": 7, "html_url": "https://git.example/o/r/issues/7"}
)
if request.method == "POST" and path == "/repos/o/r/issues/7/assets":
name = request.url.params["name"]
if name == self.fail_asset:
self.fail_asset = None
return httpx.Response(500, json={"message": "boom"})
return httpx.Response(
201, json={"name": name, "browser_download_url": f"https://git.example/a/{name}"}
)
if request.method == "PATCH" and path == "/repos/o/r/issues/7":
self.patched_body = json.loads(request.content)["body"]
return httpx.Response(201, json={"number": 7})
if request.method == "POST" and path == "/markdown":
return httpx.Response(200, text="<h3>Steps</h3>")
return httpx.Response(404, json={"message": "not found"})
def uploads(self) -> list[str]:
return [p for m, p in self.calls if p.endswith("/assets")]
@pytest.fixture
def gitea_fake(monkeypatch):
from app.config import settings
from app.issues import gitea
from app.routes import debug_sessions as routes
fake = FakeGitea()
real = httpx.Client
monkeypatch.setattr(
gitea.httpx, "Client", lambda **kw: real(transport=httpx.MockTransport(fake.handler), **kw)
)
monkeypatch.setattr(settings, "issues_gitea_url", "https://git.example/")
monkeypatch.setattr(settings, "issues_gitea_repo", "o/r")
monkeypatch.setattr(settings, "issues_gitea_token", "t0ken")
routes._new_issues.reset()
yield fake
routes._new_issues.reset()
def _ready(client, sid, title="Spinner stays"):
r = client.put(f"/api/debug-sessions/{sid}/draft", json={"title": title, "body": "It spins."})
assert r.status_code == 200
def test_without_a_tracker_the_report_can_only_be_downloaded(db, client, make_user):
_signed_in(client, make_user)
assert client.get("/api/me").json()["issues"] is None
sid = _start(client)["id"]
_ready(client, sid)
r = client.post(f"/api/debug-sessions/{sid}/issue")
assert r.status_code == 400 and r.json()["detail"]["reason"] == "issues_not_configured"
def test_an_issue_is_created_with_every_file_and_the_screenshots_shown(
gitea_fake, db, client, make_user
):
_signed_in(client, make_user)
assert client.get("/api/me").json()["issues"] == {"repo": "o/r"}
sid = _start(client)["id"]
_upload(client, sid, _PNG)
_ready(client, sid)
r = client.post(f"/api/debug-sessions/{sid}/issue")
assert r.status_code == 200, r.text
session = r.json()["session"]
assert session["issue_url"] == "https://git.example/o/r/issues/7"
assert session["pending_files"] == []
# One file per request (the measured API), each by its name.
assert gitea_fake.uploads() == ["/repos/o/r/issues/7/assets"] * 3
assert "![screenshot-1.png](https://git.example/a/screenshot-1.png)" in gitea_fake.patched_body
assert gitea_fake.patched_body.startswith("It spins.")
row = db.scalar(select(AuditLog).where(AuditLog.action == "issue_create"))
assert (row.target_id, row.summary) == (sid, "https://git.example/o/r/issues/7")
# Sent: the draft is final, and sending again sends nothing.
assert (
client.put(f"/api/debug-sessions/{sid}/draft", json={"title": "x", "body": "y"}).status_code
== 409
)
calls = len(gitea_fake.calls)
assert client.post(f"/api/debug-sessions/{sid}/issue").status_code == 200
assert len(gitea_fake.calls) == calls
def test_a_public_repository_gets_nothing(gitea_fake, db, client, make_user):
gitea_fake.private = False
_signed_in(client, make_user)
sid = _start(client)["id"]
_ready(client, sid)
r = client.post(f"/api/debug-sessions/{sid}/issue")
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_public_repo"
assert [m for m, _ in gitea_fake.calls] == ["GET"]
def test_a_send_that_stops_half_way_keeps_the_issue_and_the_retry_finishes_it(
gitea_fake, db, client, make_user
):
gitea_fake.fail_asset = "server.log"
_signed_in(client, make_user)
sid = _start(client)["id"]
_ready(client, sid)
r = client.post(f"/api/debug-sessions/{sid}/issue")
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_unreachable"
session = client.get(f"/api/debug-sessions/{sid}").json()
assert session["issue_url"] == "https://git.example/o/r/issues/7"
assert session["pending_files"] == ["server.log"]
r = client.post(f"/api/debug-sessions/{sid}/issue")
assert r.status_code == 200 and r.json()["session"]["pending_files"] == []
# One issue, and the retry sent only what was missing.
assert [p for m, p in gitea_fake.calls if (m, p) == ("POST", "/repos/o/r/issues")] == [
"/repos/o/r/issues"
]
assert len(gitea_fake.uploads()) == 3 # client.log, the failed server.log, then server.log
def test_a_report_needs_a_title_and_a_wrong_token_says_so(gitea_fake, db, client, make_user):
_signed_in(client, make_user)
sid = _start(client)["id"]
r = client.post(f"/api/debug-sessions/{sid}/issue")
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issue_title_required"
_ready(client, sid)
gitea_fake.token_ok = False
r = client.post(f"/api/debug-sessions/{sid}/issue")
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_bad_token"
assert client.get(f"/api/debug-sessions/{sid}").json()["issue_url"] is None
def test_new_issues_are_paced_per_user(gitea_fake, db, make_user, as_user, monkeypatch):
from app.ratelimit import RateLimiter
from app.routes import debug_sessions as routes
monkeypatch.setattr(routes, "_new_issues", RateLimiter(max_events=1, window_seconds=3600))
client = as_user(make_user())
first = _start(client)["id"]
_ready(client, first)
assert client.post(f"/api/debug-sessions/{first}/issue").status_code == 200
client.post(f"/api/debug-sessions/{first}/stop")
second = _start(client)["id"]
_ready(client, second)
r = client.post(f"/api/debug-sessions/{second}/issue")
assert r.status_code == 429 and r.json()["detail"]["reason"] == "issues_rate_limited"
def test_the_preview_is_the_trackers_own_rendering(gitea_fake, db, make_user, as_user):
owner, stranger = as_user(make_user()), as_user(make_user())
sid = _start(owner)["id"]
r = owner.post(f"/api/debug-sessions/{sid}/preview", json={"text": "### Steps"})
assert r.json() == {"ok": True, "html": "<h3>Steps</h3>"}
assert (
stranger.post(f"/api/debug-sessions/{sid}/preview", json={"text": "x"}).status_code == 404
)
assert stranger.post(f"/api/debug-sessions/{sid}/issue").status_code == 404
def test_the_admin_connection_test_names_what_is_wrong(gitea_fake, db, make_user, as_user):
admin = as_user(make_user(role="admin"))
r = admin.post("/api/admin/config/test-issues")
assert r.json() == {"ok": True, "repo": "o/r", "url": "https://git.example/o/r"}
gitea_fake.has_issues = False
r = admin.post("/api/admin/config/test-issues")
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_disabled"
assert as_user(make_user()).post("/api/admin/config/test-issues").status_code == 403