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
@@ -0,0 +1,116 @@
<!doctype html><!-- TPSchedule school diagnostic --><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; form-action 'none'"><title>TPSchedule school diagnostic</title></head><body><input type="hidden" name="semester.id" value="523"><table id="manualArrangeCourseTable"><tbody><tr><th>节次</th><th>星期一</th><th>星期二</th><th>星期三</th><th>星期四</th><th>星期五</th><th>星期六</th><th>星期日</th></tr></tbody></table><table><tbody><tr><th>序号</th><th>课程代码</th><th>课程名称</th><th>课程类别</th><th>学分</th><th>课程序号</th><th>教师</th><th>职称</th><th>教材信息</th><th>选课备注</th></tr><tr><td>1</td><td>C1</td><td>课程1</td><td>专业课</td><td>3</td><td>C1.01</td><td>教师1</td><td></td><td></td><td>上课时间:19:00-22:00</td></tr><tr><td>2</td><td>C2</td><td>课程2</td><td>专业课</td><td>3</td><td>C2.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>3</td><td>C3</td><td>课程3</td><td>专业课</td><td>3</td><td>C3.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>4</td><td>C4</td><td>课程4</td><td>专业课</td><td>3</td><td>C4.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>5</td><td>C5</td><td>课程5</td><td>专业课</td><td>3</td><td>C5.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>6</td><td>C6</td><td>课程6</td><td>专业课</td><td>3</td><td>C6.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>7</td><td>C7</td><td>课程7</td><td>专业课</td><td>3</td><td>C7.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>8</td><td>C8</td><td>课程8</td><td>专业课</td><td>3</td><td>C8.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>9</td><td>C9</td><td>课程9</td><td>专业课</td><td>3</td><td>C9.01</td><td>教师1</td><td></td><td></td><td></td></tr><tr><td>10</td><td>C10</td><td>课程10</td><td>专业课</td><td>3</td><td>C10.01</td><td>教师1</td><td></td><td></td><td></td></tr></tbody></table><script type="application/x-tpschedule-course-data">var unitCount = 12;
activity = new TaskActivity("T1","教师1","COURSE1","课程1(C1.01)","R1","教室1","00000000000001000000000000000000000000000000000000000");
index = 2*unitCount+0;
table0.activities[index][table0.activities[index].length]=activity;
index = 2*unitCount+1;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T1","教师1","COURSE1","课程1(C1.01)","R1","教室1","00000000001101000000000000000000000000000000000000000");
index = 4*unitCount+2;
table0.activities[index][table0.activities[index].length]=activity;
index = 4*unitCount+3;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T1","教师1","COURSE1","课程1(C1.01)","R1","教室2","01110011110010000000000000000000000000000000000000000");
index = 4*unitCount+2;
table0.activities[index][table0.activities[index].length]=activity;
index = 4*unitCount+3;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T1","教师1","COURSE1","课程1(C1.01)","R1","教室2","01111111111110000000000000000000000000000000000000000");
index = 2*unitCount+0;
table0.activities[index][table0.activities[index].length]=activity;
index = 2*unitCount+1;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T2","教师2","COURSE2","课程2(C2.01)","R2","教室3","01111000000000000000000000000000000000000000000000000");
index = 1*unitCount+8;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+9;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+10;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+11;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T2","教师2","COURSE2","课程2(C2.01)","R2","教室4","00000111100000000000000000000000000000000000000000000");
index = 1*unitCount+8;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+9;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+10;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+11;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T3","教师3","COURSE3","课程3(C3.01)","R3","教室5","01111011111110000000000000000000000000000000000000000");
index = 3*unitCount+4;
table0.activities[index][table0.activities[index].length]=activity;
index = 3*unitCount+5;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T3","教师3","COURSE3","课程3(C3.01)","R3","教室5","01111111111111000000000000000000000000000000000000000");
index = 1*unitCount+0;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+1;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T4","教师4","COURSE4","课程4(C4.01)","R4","教室6","01111011111000000000000000000000000000000000000000000");
index = 3*unitCount+0;
table0.activities[index][table0.activities[index].length]=activity;
index = 3*unitCount+1;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T4","教师4","COURSE4","课程4(C4.01)","R4","教室6","01111111111100000000000000000000000000000000000000000");
index = 1*unitCount+2;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+3;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T5","教师5","COURSE5","课程5(C5.01)","R5","教室7","01111111111111111110000000000000000000000000000000000");
index = 0*unitCount+8;
table0.activities[index][table0.activities[index].length]=activity;
index = 0*unitCount+9;
table0.activities[index][table0.activities[index].length]=activity;
index = 0*unitCount+10;
table0.activities[index][table0.activities[index].length]=activity;
index = 0*unitCount+11;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T6","教师6、教师7","COURSE6","课程6(C6.01)","R6","教室8","01111111111110000000000000000000000000000000000000000");
index = 2*unitCount+2;
table0.activities[index][table0.activities[index].length]=activity;
index = 2*unitCount+3;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T6","教师6、教师7","COURSE6","课程6(C6.01)","R6","教室8","01111111111110000000000000000000000000000000000000000");
index = 0*unitCount+2;
table0.activities[index][table0.activities[index].length]=activity;
index = 0*unitCount+3;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T7","教师8、教师9、教师10、教师7、教师11、教师12、教师13、教师14、教师15","COURSE7","课程7(C7.01)","R7","教室9","00001011111111111000000000000000000000000000000000000");
index = 3*unitCount+6;
table0.activities[index][table0.activities[index].length]=activity;
index = 3*unitCount+7;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T8","教师16","COURSE8","课程8(C8.01)","R8","教室10","01111101111110000000000000000000000000000000000000000");
index = 1*unitCount+4;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+5;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+6;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+7;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T8","教师16","COURSE8","课程8(C8.01)","R8","停课","00000010000000000000000000000000000000000000000000000");
index = 1*unitCount+4;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+5;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+6;
table0.activities[index][table0.activities[index].length]=activity;
index = 1*unitCount+7;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T9","教师17","COURSE9","课程9(C9.01)","R9","教室12","00000000000000010000000000000000000000000000000000000");
index = 6*unitCount+2;
table0.activities[index][table0.activities[index].length]=activity;
index = 6*unitCount+3;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T10","教师18","COURSE10","课程10(C10.01)","R10","教室13","01111111111110000000000000000000000000000000000000000");
index = 0*unitCount+4;
table0.activities[index][table0.activities[index].length]=activity;
index = 0*unitCount+5;
table0.activities[index][table0.activities[index].length]=activity;
activity = new TaskActivity("T10","教师18","COURSE10","课程10(C10.01)","R10","教室13","01111111111110000000000000000000000000000000000000000");
index = 2*unitCount+4;
table0.activities[index][table0.activities[index].length]=activity;
index = 2*unitCount+5;
table0.activities[index][table0.activities[index].length]=activity;</script></body></html>
+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():