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:
@@ -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 "" 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
|
||||
|
||||
Reference in New Issue
Block a user