fix: import unpublished exam rooms and add opt-in school HTML feedback

This commit is contained in:
Codex
2026-10-16 16:38:00 +08:00
parent defe7fd50e
commit 437ed14dbe
31 changed files with 969 additions and 39 deletions
+63 -2
View File
@@ -4,6 +4,7 @@ import threading
import tempfile
import time
import smtplib
from pathlib import Path
from types import SimpleNamespace
import feedback_service
@@ -15,6 +16,66 @@ from feedback_service import Settings, SendFailure, UncertainFailure, SmtpSender
ID = "cbaaaace-60b2-4871-8a95-ae316444a2fb"
PNG = b"\x89PNG\r\n\x1a\n" + b"safe fixture"
HTML = (b'<!doctype html><!-- TPSchedule school diagnostic --><html><head><meta charset="utf-8">'
b'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; base-uri \'none\'; form-action \'none\'">'
b'</head><body><table></table></body></html>')
def test_real_client_generated_synthetic_html_is_accepted_as_attachment(service):
client, sender, _ = service
page = (Path(__file__).parent / 'fixtures/client-school-diagnostic.html').read_bytes()
fields = [('submission_id', (None, ID)), ('type', (None, 'problem')), ('message', (None, 'Cannot import'))]
response = client.post('/v1/feedback', files=fields + [('diagnostic_html', ('page.html', page, 'text/html'))])
assert response.status_code == 200
attachment = list(sender.messages[0].iter_attachments())[0]
assert attachment.get_payload(decode=True) == page
@pytest.mark.parametrize('extra', [
'<script>alert(1)</script>', '<img src="https://evil.test/">',
'<table onclick="alert(1)"></table>', '<meta http-equiv="refresh" content="0;url=https://evil.test/">',
'<iframe srcdoc="x"></iframe>',
])
def test_html_diagnostic_rejects_active_or_network_content_even_with_marker(service, extra):
client, sender, _ = service
data = HTML.replace(b'</body>', extra.encode() + b'</body>')
fields = [('submission_id', (None, ID)), ('type', (None, 'problem')), ('message', (None, 'Cannot import'))]
response = client.post('/v1/feedback', files=fields + [('diagnostic_html', ('page.html', data, 'text/html'))])
assert response.status_code == 415
assert not sender.messages
def test_html_diagnostic_is_fifth_attachment_and_retry_uses_identical_snapshot(service):
client, sender, _ = service
files = [('submission_id', (None, ID)), ('type', (None, 'problem')), ('message', (None, 'Cannot import'))]
files += [('attachments', (f'{i}.png', PNG, 'image/png')) for i in range(3)]
files += [('diagnostics', ('log.txt', b'TPSchedule diagnostic log\n', 'text/plain')),
('diagnostic_html', ('page.html', HTML, 'text/html; charset=utf-8'))]
assert client.post('/v1/feedback', files=files).status_code == 200
attachments = list(sender.messages[0].iter_attachments())
assert len(attachments) == 5
assert attachments[-1].get_filename() == 'TPSchedule-school-page.html'
assert attachments[-1].get_payload(decode=True) == HTML
assert client.post('/v1/feedback', files=files).status_code == 200
assert len(sender.messages) == 1
files[-1] = ('diagnostic_html', ('page.html', HTML.replace(b'<table>', b'<table><tr><td>changed</td></tr>'), 'text/html'))
assert client.post('/v1/feedback', files=files).status_code == 409
@pytest.mark.parametrize('data,mime,kind,status', [
(b'not HTML', 'text/html', 'problem', 415),
(HTML, 'image/png', 'problem', 415),
(HTML + b'\xff', 'text/html', 'problem', 415),
(HTML + b'x' * (2 * 1024 * 1024), 'text/html', 'problem', 413),
(HTML, 'text/html', 'development', 422),
], ids=['header', 'mime', 'utf8', 'oversize', 'development'])
def test_invalid_html_diagnostics_never_send(service, data, mime, kind, status):
client, sender, _ = service
fields = [('submission_id', (None, ID)), ('type', (None, kind))]
fields += [('message', (None, 'Cannot import'))] if kind == 'problem' else [('school', (None, 'School')), ('contact', (None, 'Contact'))]
response = client.post('/v1/feedback', files=fields + [('diagnostic_html', ('page.html', data, mime))])
assert response.status_code == status
assert not sender.messages
class RecordingSender:
@@ -251,8 +312,8 @@ def test_aggregate_attachments_are_limited(service):
def test_chunked_body_without_content_length_is_limited(service):
client, sender, _ = service
events, output = iter([
{"type": "http.request", "body": b"x" * (11 * 1024 * 1024), "more_body": True},
{"type": "http.request", "body": b"x" * (11 * 1024 * 1024), "more_body": False},
{"type": "http.request", "body": b"x" * (12 * 1024 * 1024), "more_body": True},
{"type": "http.request", "body": b"x" * (12 * 1024 * 1024), "more_body": False},
]), []
async def receive():