Files
coredesktop/server/report-receiver/test_receiver.py
T
davidandClaude Opus 5.5 4a4a3bdd36
shellcheck / shellcheck (push) Failing after 17s
Report receiver: keep its log out of the test output
The receiver logs each report it files in Gitea, or fails to, from a
background thread. Those lines landed in the middle of the test results.
Capture stderr in each test instead, in self.stderr for tests that want
to check it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 06:13:20 -07:00

224 lines
10 KiB
Python

"""Tests for receiver.py. Run with: python3 -I -m unittest discover -s server/report-receiver
They start the receiver on a free port with a fake Gitea, and send it reports
with the real `report` client, the way a CoreDesktop machine would."""
import contextlib
import gzip
import http.server
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import unittest
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import receiver # noqa: E402
REPO = Path(__file__).resolve().parents[2]
class FakeGitea:
"""Records the issues and attachments it's given; fail=True answers 500."""
def __init__(self):
self.issues = []
self.attachments = []
self.fail = False
fake = self
class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
data = self.rfile.read(int(self.headers["Content-Length"]))
if fake.fail:
self.send_response(500)
self.end_headers()
return
if self.path.endswith("/issues"):
fake.issues.append({"path": self.path, "auth": self.headers["Authorization"], **json.loads(data)})
reply = {"number": len(fake.issues)}
else:
fields = receiver.parse_multipart(self.headers["Content-Type"], data)
fake.attachments.append({"path": self.path, "text": fields["attachment"].decode()})
reply = {"id": len(fake.attachments)}
body = json.dumps(reply).encode()
self.send_response(201)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.url = f"http://127.0.0.1:{self.server.server_port}"
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def close(self):
self.server.shutdown()
self.server.server_close()
class ReceiverTest(unittest.TestCase):
def setUp(self):
# Keep what the receiver logs ("filed as issue #1", ...) out of the
# test results. It's in self.stderr for a test that wants to check it.
self.stderr = io.StringIO()
self.enterContext(contextlib.redirect_stderr(self.stderr))
self.tmp = Path(tempfile.mkdtemp())
self.gitea = FakeGitea()
self.store = receiver.Store(self.tmp / "state", receiver.Gitea(self.gitea.url, "Federated_Computer/coredesktop-reports", "secret"))
self.limiter = receiver.RateLimiter()
handler = receiver.make_handler(self.store, self.limiter)
handler.log_message = lambda *args: None
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
self.url = f"http://127.0.0.1:{self.server.server_port}/report"
threading.Thread(target=self.server.serve_forever, daemon=True).start()
# A CoreDesktop home with one failed update's log
self.home = self.tmp / "home"
logs = self.home / ".local/state/coredesktop/logs"
logs.mkdir(parents=True)
self.log = logs / "update-2026-11-08-110307.log"
self.log.write_text(f"Updating CoreDesktop...\nCloning into '{self.home}/x'...\n"
"Done, with errors in: 71-siblings.sh\n")
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.gitea.close()
shutil.rmtree(self.tmp)
def send(self, *args):
"""Run the report client, answering yes, and return its output."""
tty = self.tmp / "tty"
tty.write_text("y\n")
env = {"HOME": str(self.home), "USER": "alice", "PATH": os.environ["PATH"],
"TMPDIR": str(self.tmp), "COREDESKTOP_REPORT_URL": self.url, "COREDESKTOP_TTY": str(tty)}
result = subprocess.run(["bash", str(REPO / "report"), *args], env=env,
capture_output=True, text=True, timeout=60)
return result.stdout + result.stderr
def wait_for(self, condition):
for _ in range(100):
if condition():
return
time.sleep(0.05)
self.fail("timed out")
def test_a_report_from_the_client_is_filed_with_its_log(self):
output = self.send("--offer", str(self.log), "71-siblings.sh", "46-neovim.sh")
self.assertIn("Your report's ID is CD-", output)
report_id = output.split("Your report's ID is ")[1].split(";")[0]
# Filed means attached and no longer pending, which happens just after
self.wait_for(lambda: not (self.tmp / "state" / report_id / "pending").exists())
issue = self.gitea.issues[0]
self.assertEqual(issue["path"], "/api/v1/repos/Federated_Computer/coredesktop-reports/issues")
self.assertEqual(issue["auth"], "token secret")
self.assertTrue(issue["title"].startswith("Update failed in 71-siblings.sh, 46-neovim.sh"))
self.assertIn(report_id, issue["body"])
self.assertIn("Cloning into '~/x'...", issue["body"])
self.assertNotIn(str(self.home), issue["body"])
attachment = self.gitea.attachments[0]
self.assertEqual(attachment["path"], "/api/v1/repos/Federated_Computer/coredesktop-reports/issues/1/assets")
self.assertIn("Done, with errors in: 71-siblings.sh", attachment["text"])
self.assertFalse((self.tmp / "state" / report_id / "pending").exists())
def test_a_report_gitea_refuses_is_kept_and_filed_later(self):
self.gitea.fail = True
output = self.send(str(self.log))
self.assertIn("Your report's ID is CD-", output)
report_id = output.split("Your report's ID is ")[1].split(";")[0]
self.wait_for(lambda: not self.store.lock.locked() and (self.tmp / "state" / report_id / "pending").exists())
time.sleep(0.2)
self.assertEqual(self.gitea.issues, [])
self.gitea.fail = False
self.store.retry_and_prune()
self.assertEqual(len(self.gitea.issues), 1)
self.assertEqual(len(self.gitea.attachments), 1)
self.assertFalse((self.tmp / "state" / report_id / "pending").exists())
def test_an_attachment_that_failed_is_retried_without_a_second_issue(self):
report_id = self.store.save({"kind": "update", "version": "", "failed": "x", "kernel": "", "siblings": ""}, "log\n")
original = self.store.gitea.attach
self.store.gitea.attach = lambda *args: (_ for _ in ()).throw(OSError("Gitea is down"))
self.assertFalse(self.store.deliver(report_id))
self.store.gitea.attach = original
self.assertTrue(self.store.deliver(report_id))
self.assertEqual(len(self.gitea.issues), 1)
self.assertEqual(len(self.gitea.attachments), 1)
def post(self, body, content_type="multipart/form-data; boundary=x"):
request = urllib.request.Request(self.url, data=body, method="POST", headers={"Content-Type": content_type})
try:
with urllib.request.urlopen(request) as response:
return response.status, response.read().decode()
except urllib.error.HTTPError as error:
with error:
return error.code, error.read().decode()
def test_things_that_arent_reports_are_turned_away(self):
self.assertEqual(self.post(b"hello", "text/plain")[0], 400)
no_log = b'--x\r\nContent-Disposition: form-data; name="kind"\r\n\r\nupdate\r\n--x--\r\n'
self.assertEqual(self.post(no_log)[0], 400)
not_gzip = b'--x\r\nContent-Disposition: form-data; name="log"; filename="log.gz"\r\n\r\nplain\r\n--x--\r\n'
self.assertEqual(self.post(not_gzip), (400, "The log in this report couldn't be read.\n"))
self.assertEqual(self.gitea.issues, [])
def test_a_gzip_bomb_is_refused(self):
bomb = gzip.compress(b"\0" * (receiver.MAX_LOG_BYTES + 1))
body = (b'--x\r\nContent-Disposition: form-data; name="log"; filename="log.gz"\r\n'
b'Content-Type: application/gzip\r\n\r\n' + bomb + b'\r\n--x--\r\n')
self.assertEqual(self.post(body)[0], 413)
def test_too_many_reports_from_one_address_are_refused(self):
for _ in range(5):
self.assertIn("Your report's ID is", self.send(str(self.log)))
output = self.send(str(self.log))
self.assertIn("Couldn't send the report: Too many reports from this address", output)
def test_other_paths_are_not_found(self):
request = urllib.request.Request(self.url.replace("/report", "/other"), data=b"x", method="POST")
with self.assertRaises(urllib.error.HTTPError) as caught:
urllib.request.urlopen(request)
caught.exception.close()
self.assertEqual(caught.exception.code, 404)
class PartsTest(unittest.TestCase):
def test_the_rate_limiter_counts_per_address_and_forgets_old_reports(self):
limiter = receiver.RateLimiter(per_address=[(3600, 2)], daily=3)
limiter.check("a", now=0)
limiter.check("a", now=1)
with self.assertRaises(receiver.Rejected):
limiter.check("a", now=2)
limiter.check("a", now=3700)
with self.assertRaises(receiver.Rejected): # the daily limit for everyone
limiter.check("b", now=3701)
def test_issue_text_cant_break_out_of_its_table_or_code_block(self):
details = {"kind": "update", "version": "v | 1", "failed": "a\nb", "kernel": "", "siblings": ""}
body = receiver.issue_body("CD-TEST23", details, "````\nlog\n", "now")
self.assertIn("| CoreDesktop | v \\| 1 |", body)
self.assertIn("| Failed | a<br>b |", body)
self.assertIn("`````\n````\nlog\n`````", body)
self.assertEqual(receiver.issue_title(details), "Update failed in a b (v | 1)")
def test_ids_are_easy_to_read_out(self):
for _ in range(100):
report_id = receiver.new_id()
self.assertRegex(report_id, r"^CD-[2-9A-HJ-NP-Z]{6}$")
if __name__ == "__main__":
unittest.main()