Add the receiver that files error reports as Gitea issues

server/report-receiver/receiver.py takes what report sends to
federated.sh/report, behind Caddy, and files each report as an issue in
the private Federated_Computer/coredesktop-reports repository with the
full log attached. It saves each report before replying and tries Gitea
again later if it's down, limits how many reports one address can send,
and refuses oversized logs, gzip bombs included. Standard library only.

The README has the deployment steps: a Gitea user that can only file
issues there, the systemd unit, and the Caddy route. make test runs its
tests, which send reports with the real report client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 11:36:43 -07:00
co-authored by Claude Opus 5.5
parent a82bbfc2e1
commit b7fb098de7
6 changed files with 681 additions and 0 deletions
+1
View File
@@ -12,3 +12,4 @@ lint:
test:
bats tests
python3 -I -m unittest discover -s server/report-receiver
+20
View File
@@ -0,0 +1,20 @@
# What federated.sh's site block needs, wherever its Caddyfile is. `handle`
# blocks are tried in order and only the first match runs, so /report goes to
# the receiver and everything else does what it did before. A top-level `redir`
# runs before any `handle`, so the existing redirect has to move into the
# catch-all `handle` below, or POSTs to /report would be redirected too.
federated.sh {
@report path /report /report/*
handle @report {
request_body {
max_size 11MB
}
reverse_proxy 127.0.0.1:8650
}
handle {
# ...the existing directives for federated.sh, unchanged, e.g. the
# redirect that sends wget to install.sh
}
}
+61
View File
@@ -0,0 +1,61 @@
# Report receiver
Takes the error reports CoreDesktop machines send with `report` (see the top of
`../../report` for the request format) at `https://federated.sh/report`, and
files each as an issue in the private Gitea repository
`Federated_Computer/coredesktop-reports`, with the full log attached.
- `receiver.py` listens on 127.0.0.1:8650, behind Caddy. Standard library only.
- It saves each report in `/var/lib/coredesktop-reports/<ID>/` before replying,
so a report isn't lost if Gitea is down, and tries again every 10 minutes.
Saved reports are deleted after 90 days; the Gitea issues stay.
- It turns away more than 5 reports an hour or 20 a day from one address, and
more than 500 a day in all, and logs bigger than 10 MB compressed or 64 MB
uncompressed.
- `make test` in the repository runs `test_receiver.py`, which sends reports
with the real `report` client to the receiver and a fake Gitea.
## Deploying on federated.sh
1. **A Gitea user that can only file issues there.** Gitea tokens can't be
limited to one repository, so make a separate user (say
`coredesktop-reports-bot`), add it to `Federated_Computer/coredesktop-reports`
as a collaborator with write access, and make it a token with only the
`write:issue` permission. Nothing else should be shared with that user.
2. **Install the receiver** (as root):
```
install -d -m 755 /opt/coredesktop-reports
install -m 644 receiver.py /opt/coredesktop-reports/receiver.py
install -m 644 coredesktop-reports.service /etc/systemd/system/coredesktop-reports.service
install -m 600 /dev/null /etc/coredesktop-reports.env
```
Then put the settings in `/etc/coredesktop-reports.env`:
```
GITEA_URL=https://gitea.federated.computer
GITEA_REPO=Federated_Computer/coredesktop-reports
GITEA_TOKEN=<the bot's token>
```
```
systemctl daemon-reload
systemctl enable --now coredesktop-reports
curl -s http://127.0.0.1:8650/report/health # prints ok
```
3. **Route /report to it in Caddy.** See `Caddyfile.example`: `/report` goes
in its own `handle` block, and the site's existing directives go in a
catch-all `handle`, so the installer redirect keeps working. Check with
`caddy validate`, then `systemctl reload caddy`.
4. **Check it end to end.** `curl -s https://federated.sh/report/health`
prints ok, `wget -qO- https://federated.sh | head -3` still shows
install.sh, and from a CoreDesktop machine `report` sends its newest log
and prints an ID, which appears as a new issue in the repository.
To update it later, install the new `receiver.py` the same way and run
`systemctl restart coredesktop-reports`. `journalctl -u coredesktop-reports`
shows each report it receives and files.
@@ -0,0 +1,33 @@
[Unit]
Description=CoreDesktop error-report receiver (federated.sh/report)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/python3 -I /opt/coredesktop-reports/receiver.py
# GITEA_URL, GITEA_REPO and GITEA_TOKEN; readable by root only
EnvironmentFile=/etc/coredesktop-reports.env
DynamicUser=yes
StateDirectory=coredesktop-reports
StateDirectoryMode=0700
Restart=on-failure
RestartSec=5
# It only needs to listen on 127.0.0.1, reach Gitea and write its state directory
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
MemoryMax=512M
[Install]
WantedBy=multi-user.target
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""Receive CoreDesktop error reports and file them as Gitea issues.
The `report` command on CoreDesktop machines POSTs reports here, through Caddy
on federated.sh. See the top of `report` for the request format. Each report
is saved under the state directory first, so none is lost if Gitea is down,
then filed as an issue in a private repository, with the full log attached.
Reports Gitea didn't take are tried again every few minutes.
Runs with only the standard library. Settings come from the environment:
GITEA_URL e.g. https://gitea.federated.computer
GITEA_REPO e.g. Federated_Computer/coredesktop-reports
GITEA_TOKEN a token that can create issues in that repository
LISTEN_PORT port on 127.0.0.1 to listen on (default 8650)
STATE_DIRECTORY where reports are kept (systemd sets this)
Saved reports are deleted after KEEP_DAYS days; their Gitea issues stay.
"""
import email.parser
import email.policy
import gzip
import http.server
import io
import json
import os
import re
import secrets
import shutil
import sys
import threading
import time
import urllib.error
import urllib.request
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
MAX_REQUEST_BYTES = 11 * 1024 * 1024 # the client sends at most 10 MB of log
MAX_LOG_BYTES = 64 * 1024 * 1024 # uncompressed, so a gzip bomb can't fill memory
FIELD_LIMITS = {"kind": 20, "version": 100, "failed": 1000, "kernel": 100, "siblings": 4000}
KINDS = {"install", "update", "other"}
PER_ADDRESS_LIMITS = [(3600, 5), (86400, 20)] # (seconds, reports) per client address
DAILY_LIMIT = 500 # from everyone together
EXCERPT_LINES = 150
RETRY_SECONDS = 600
KEEP_DAYS = 90
class Rejected(Exception):
"""A request we turn away, with the status and the message for the user."""
def __init__(self, status, message):
super().__init__(message)
self.status = status
self.message = message
class RateLimiter:
def __init__(self, per_address=PER_ADDRESS_LIMITS, daily=DAILY_LIMIT):
self.per_address = per_address
self.daily = daily
self.times = {}
self.everyone = deque()
self.lock = threading.Lock()
def check(self, address, now=None):
"""Count a report from address, or raise Rejected if it's one too many."""
now = time.time() if now is None else now
longest = max(window for window, _ in self.per_address)
with self.lock:
while self.everyone and self.everyone[0] <= now - 86400:
self.everyone.popleft()
if len(self.everyone) >= self.daily:
raise Rejected(429, "We're getting a lot of reports right now; please try again tomorrow.")
times = self.times.setdefault(address, deque())
while times and times[0] <= now - longest:
times.popleft()
for window, limit in self.per_address:
if sum(1 for t in times if t > now - window) >= limit:
raise Rejected(429, "Too many reports from this address; please try again later.")
times.append(now)
self.everyone.append(now)
# Forget addresses with nothing recent, so the table can't grow without end
if len(self.times) > 10000:
self.times = {a: t for a, t in self.times.items() if t and t[-1] > now - longest}
def parse_multipart(content_type, body):
"""The fields of a multipart/form-data body, as {name: bytes}."""
if not content_type.lower().startswith("multipart/form-data"):
raise Rejected(400, "That doesn't look like a CoreDesktop report.")
head = f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode()
message = email.parser.BytesParser(policy=email.policy.HTTP).parsebytes(head + body)
if not message.is_multipart():
raise Rejected(400, "That doesn't look like a CoreDesktop report.")
fields = {}
for part in message.iter_parts():
name = part.get_param("name", header="content-disposition")
if name and name not in fields:
fields[name] = part.get_payload(decode=True) or b""
return fields
def clean_text(value, limit):
"""value as text: no control characters but newlines and tabs, at most limit characters."""
text = value.decode("utf-8", errors="replace")
text = re.sub(r"[^\S\n\t ]|[\x00-\x08\x0b-\x1f\x7f]", "", text)
return text[:limit].strip()
def read_report(fields):
"""The report in fields: its details as text, and its log, uncompressed."""
if "log" not in fields:
raise Rejected(400, "That doesn't look like a CoreDesktop report.")
details = {name: clean_text(fields.get(name, b""), limit) for name, limit in FIELD_LIMITS.items()}
if details["kind"] not in KINDS:
details["kind"] = "other"
try:
with gzip.GzipFile(fileobj=io.BytesIO(fields["log"])) as stream:
log = stream.read(MAX_LOG_BYTES + 1)
except (OSError, EOFError):
raise Rejected(400, "The log in this report couldn't be read.")
if len(log) > MAX_LOG_BYTES:
raise Rejected(413, "This report's log is too big.")
return details, log.decode("utf-8", errors="replace")
def new_id():
"""A report ID such as CD-7Q2K9M: easy to read out, with no 0/O or 1/I."""
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
return "CD-" + "".join(secrets.choice(alphabet) for _ in range(6))
def fence(text):
"""A code fence longer than any run of backticks in text."""
longest = max((len(run) for run in re.findall(r"`+", text)), default=0)
return "`" * max(3, longest + 1)
def cell(text):
"""text for one cell of a Markdown table."""
return text.replace("\\", "\\\\").replace("|", "\\|").replace("\n", "<br>") or "–"
def issue_title(details):
failed = details["failed"].replace("\n", " ") or "an unknown step"
title = f"{details['kind'].capitalize()} failed in {failed}"
if details["version"]:
title += f" ({details['version']})"
return title[:200]
def issue_body(report_id, details, log, received):
lines = log.rstrip("\n").split("\n")
excerpt = "\n".join(lines[-EXCERPT_LINES:])
marks = fence(excerpt)
shown = "Its last %d lines:" % EXCERPT_LINES if len(lines) > EXCERPT_LINES else "The log:"
return "\n".join([
f"Report **{report_id}**, received {received}.",
"",
"| | |",
"|---|---|",
f"| Kind | {cell(details['kind'])} |",
f"| CoreDesktop | {cell(details['version'])} |",
f"| Failed | {cell(details['failed'])} |",
f"| Kernel | {cell(details['kernel'])} |",
f"| Siblings | {cell(details['siblings'])} |",
"",
f"The full log ({len(lines)} lines) is attached. {shown}",
"",
marks,
excerpt,
marks,
"",
"Sent with `report` from a CoreDesktop machine. Its user name, host name, home folder, "
"email and IP addresses were replaced before sending.",
])
class Gitea:
def __init__(self, url, repo, token, timeout=30):
self.base = f"{url.rstrip('/')}/api/v1/repos/{repo}"
self.token = token
self.timeout = timeout
def _request(self, path, data, content_type):
request = urllib.request.Request(self.base + path, data=data, method="POST", headers={
"Authorization": f"token {self.token}",
"Content-Type": content_type,
"Accept": "application/json",
})
with urllib.request.urlopen(request, timeout=self.timeout) as response:
return json.load(response)
def create_issue(self, title, body):
"""The new issue's number."""
data = json.dumps({"title": title, "body": body}).encode()
return self._request("/issues", data, "application/json")["number"]
def attach(self, number, filename, text):
boundary = "coredesktop-" + secrets.token_hex(16)
data = b"".join([
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="attachment"; filename="{filename}"\r\n'.encode(),
b"Content-Type: text/plain; charset=utf-8\r\n\r\n",
text.encode("utf-8"),
f"\r\n--{boundary}--\r\n".encode(),
])
self._request(f"/issues/{number}/assets", data, f"multipart/form-data; boundary={boundary}")
class Store:
"""Reports on disk: <id>/report.json and <id>/log.txt, and <id>/pending
until it's filed in Gitea."""
def __init__(self, directory, gitea):
self.directory = Path(directory)
self.gitea = gitea
self.lock = threading.Lock() # one delivery at a time, so none is filed twice
def save(self, details, log):
received = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
for _ in range(10):
report_id = new_id()
folder = self.directory / report_id
try:
folder.mkdir(parents=True)
break
except FileExistsError:
continue
else:
raise Rejected(503, "The report couldn't be saved; please try again later.")
(folder / "log.txt").write_text(log, encoding="utf-8")
(folder / "report.json").write_text(json.dumps({**details, "id": report_id, "received": received}))
(folder / "pending").touch()
return report_id
def deliver(self, report_id):
"""File one saved report in Gitea; True once it's filed."""
folder = self.directory / report_id
with self.lock:
if not (folder / "pending").exists():
return True
report = json.loads((folder / "report.json").read_text())
log = (folder / "log.txt").read_text(encoding="utf-8")
try:
number = report.get("issue")
if not number:
number = self.gitea.create_issue(issue_title(report), issue_body(report_id, report, log, report["received"]))
# Remember the issue, so a failed attachment doesn't make a second one
report["issue"] = number
(folder / "report.json").write_text(json.dumps(report))
self.gitea.attach(number, f"{report_id}-{report['kind']}.log", log)
except (urllib.error.URLError, OSError, ValueError, KeyError) as error:
print(f"{report_id}: couldn't file it in Gitea yet: {error}", file=sys.stderr, flush=True)
return False
(folder / "pending").unlink()
print(f"{report_id}: filed as issue #{number}", file=sys.stderr, flush=True)
return True
def retry_and_prune(self):
"""Try the reports Gitea hasn't taken again; delete filed ones older than KEEP_DAYS."""
if not self.directory.is_dir():
return
cutoff = time.time() - KEEP_DAYS * 86400
for folder in sorted(self.directory.iterdir()):
if not folder.is_dir():
continue
if (folder / "pending").exists():
self.deliver(folder.name)
elif folder.stat().st_mtime < cutoff:
shutil.rmtree(folder, ignore_errors=True)
def make_handler(store, limiter):
class Handler(http.server.BaseHTTPRequestHandler):
timeout = 60
server_version = "coredesktop-reports"
sys_version = ""
def reply(self, status, text):
data = (text + "\n").encode()
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def client_address_seen(self):
# Behind Caddy, the client is the first address it forwarded
forwarded = self.headers.get("X-Forwarded-For", "")
if self.client_address[0] in ("127.0.0.1", "::1") and forwarded:
return forwarded.split(",")[0].strip()
return self.client_address[0]
def do_GET(self):
if self.path == "/report/health":
self.reply(200, "ok")
else:
self.reply(404, "Not found.")
def do_POST(self):
if self.path != "/report":
self.reply(404, "Not found.")
return
try:
length = int(self.headers.get("Content-Length", ""))
except ValueError:
self.reply(411, "A report needs a Content-Length.")
return
if length > MAX_REQUEST_BYTES:
self.reply(413, "This report is too big.")
return
try:
limiter.check(self.client_address_seen())
fields = parse_multipart(self.headers.get("Content-Type", ""), self.rfile.read(length))
details, log = read_report(fields)
report_id = store.save(details, log)
except Rejected as rejected:
self.reply(rejected.status, rejected.message)
return
self.reply(200, report_id)
# Filed after replying, so the user isn't kept waiting on Gitea
threading.Thread(target=store.deliver, args=(report_id,), daemon=True).start()
def log_message(self, format, *args):
print(f"{self.client_address_seen()} {format % args}", file=sys.stderr, flush=True)
return Handler
def main():
missing = [name for name in ("GITEA_URL", "GITEA_REPO", "GITEA_TOKEN") if not os.environ.get(name)]
if missing:
sys.exit(f"Set {', '.join(missing)} (see the top of this file).")
gitea = Gitea(os.environ["GITEA_URL"], os.environ["GITEA_REPO"], os.environ["GITEA_TOKEN"])
store = Store(os.environ.get("STATE_DIRECTORY", "./reports"), gitea)
port = int(os.environ.get("LISTEN_PORT", "8650"))
server = http.server.ThreadingHTTPServer(("127.0.0.1", port), make_handler(store, RateLimiter()))
def retry_loop():
while True:
store.retry_and_prune()
time.sleep(RETRY_SECONDS)
threading.Thread(target=retry_loop, daemon=True).start()
print(f"Listening on 127.0.0.1:{port}, filing reports in {os.environ['GITEA_REPO']}", file=sys.stderr, flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+212
View File
@@ -0,0 +1,212 @@
"""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 gzip
import http.server
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()
class ReceiverTest(unittest.TestCase):
def setUp(self):
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.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]
self.wait_for(lambda: self.gitea.attachments)
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:
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)
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()