Both student-facing surfaces (the per-device join page and the
front-of-room projector) now render options as text only, no A/B/C/D
chips, no numeric prefixes. The letter-namespace remains internal:
canonical A..D in pool.json + submissions storage; canonical position
1..4 in the CSV export. Admin dashboard keeps letters because it is the
instructor's private console.
Why this lands now: the discussion of per-student option shuffling
flagged that A/B/C/D as discussion handles ("the answer is B") is itself
a leaky channel. Removing the labels closes that channel for the
non-shuffled case and keeps it closed if shuffling is added later.
Wire protocol: the submit message carries the option's full text
("answer": "Pipelining"). Server's submit_answer resolves text -> letter
via app.pool.resolve_option_key, which also accepts a canonical letter
so internal callers and tests stay readable. A non-matching string is
recorded as a zero-score submission with answer=NULL, locked in via the
PK + existing_submit_ack short-circuit. So an attempted UI bypass that
posts a fabricated string just produces a wrong answer; no retry.
CSV: A=1 .. D=2 .. C=3 .. D=4 in the answer column. Empty when no option
matched. Header unchanged so downstream pandas readers don't break, but
the value type is int instead of letter.
Histogram: the failsafe "submitted-but-no-match" row buckets into
"missed" alongside genuine misses — both yield zero credit and the
instructor cares about the same thing.
Tests:
- test_submit_accepts_option_text_resolves_to_canonical: production
wire format produces correct grading + canonical-letter storage.
- test_submit_failsafe_locks_in_zero_score_on_garbage_text: a non-
matching string is recorded at score=0 and a follow-up correct
submission cannot overwrite it.
71/71 green.
158 lines
7.2 KiB
Python
158 lines
7.2 KiB
Python
"""Anti-cheat / audit-event coverage:
|
|
- tab-blur events are recorded and surfaced in CSV + presence
|
|
- duplicate-join attempts are 409 + audited
|
|
- admin clear-student removes the participant + submissions
|
|
- submit lockout (one answer per Q per student) is server-enforced
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from conftest import admin_login, join_student
|
|
|
|
|
|
def test_blur_event_is_logged_and_counted(client, sid):
|
|
join_student(client, sid, "s1", "Alice")
|
|
response = client.post(f"/api/session/{sid}/event", json={"kind": "blur", "question_idx": 0})
|
|
assert response.status_code == 200
|
|
response = client.post(f"/api/session/{sid}/event", json={"kind": "blur", "question_idx": 0})
|
|
assert response.status_code == 200
|
|
response = client.post(f"/api/session/{sid}/event", json={"kind": "visibility_hidden"})
|
|
assert response.status_code == 200
|
|
|
|
# The event count is exposed via CSV export. Two blur events + one
|
|
# visibility_hidden event should land on the s1 row.
|
|
admin_login(client)
|
|
csv_text = client.get("/admin/api/csv").text
|
|
s1_row = next(line for line in csv_text.splitlines() if ",s1," in line)
|
|
# Trailing fields are blur_count, hidden_count, duplicate_join_attempts.
|
|
assert s1_row.endswith(",2,1,0"), s1_row
|
|
|
|
|
|
def test_event_endpoint_rejects_unknown_kind(client, sid):
|
|
join_student(client, sid, "s1", "Alice")
|
|
response = client.post(f"/api/session/{sid}/event", json={"kind": "screenshot"})
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_event_endpoint_requires_student_cookie(client, sid):
|
|
response = client.post(f"/api/session/{sid}/event", json={"kind": "blur"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_duplicate_join_is_logged_in_csv(client, sid):
|
|
"""A 409 join attempt records a `duplicate_join` audit row whose
|
|
count rolls up into CSV + presence_update."""
|
|
join_student(client, sid, "s1", "Alice")
|
|
# Second client tries to claim s1 from a fresh cookie jar.
|
|
fresh = client.__class__(client.app)
|
|
response = fresh.post(f"/api/session/{sid}/join", json={"student_id": "s1", "name": "Hijacker"})
|
|
assert response.status_code == 409
|
|
|
|
admin_login(client)
|
|
csv_text = client.get("/admin/api/csv").text
|
|
s1_row = next(line for line in csv_text.splitlines() if ",s1," in line)
|
|
assert s1_row.endswith(",0,0,1"), s1_row
|
|
|
|
|
|
def test_admin_clear_student_frees_id(client, sid):
|
|
"""First-claim-wins recovery: admin can clear a participant so the
|
|
legitimate student (or anyone, since there's no further identity
|
|
check) can re-join with that id."""
|
|
join_student(client, sid, "s1", "Alice")
|
|
admin_login(client)
|
|
response = client.delete("/admin/api/students/s1")
|
|
assert response.status_code == 200
|
|
# The slot is now free; the same id can be re-claimed from a fresh
|
|
# cookie jar.
|
|
fresh = client.__class__(client.app)
|
|
response = fresh.post(f"/api/session/{sid}/join", json={"student_id": "s1", "name": "Alice Again"})
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_admin_clear_student_404s_when_no_match(client, sid):
|
|
admin_login(client)
|
|
assert client.delete("/admin/api/students/nobody").status_code == 404
|
|
|
|
|
|
def test_post_recovery_old_cookie_is_dead(client, sid):
|
|
"""Hijack -> recovery flow: after admin clears a hijacked id and the
|
|
legitimate student re-claims with a fresh cookie_id, the original
|
|
hijacker's still-cryptographically-valid cookie must NOT continue to
|
|
authenticate. The DB cookie_id check is what closes that gap."""
|
|
Hijacker = client.__class__
|
|
hijacker = Hijacker(client.app)
|
|
response = hijacker.post(f"/api/session/{sid}/join", json={"student_id": "alice", "name": "Hijacker"})
|
|
assert response.status_code == 200
|
|
# Hijacker's cookie/me works while they hold the slot.
|
|
assert hijacker.get(f"/api/session/{sid}/me").status_code == 200
|
|
|
|
# Admin clears the hijacked id; legit student re-claims with a fresh
|
|
# browser (= fresh cookie jar = fresh signed cookie_id).
|
|
admin_login(client)
|
|
assert client.delete(f"/admin/api/students/alice").status_code == 200
|
|
legit = Hijacker(client.app)
|
|
response = legit.post(f"/api/session/{sid}/join", json={"student_id": "alice", "name": "Real Alice"})
|
|
assert response.status_code == 200
|
|
assert legit.get(f"/api/session/{sid}/me").json()["name"] == "Real Alice"
|
|
|
|
# The hijacker's old (still-cryptographically-valid) cookie now fails
|
|
# auth because its cookie_id doesn't match the DB row anymore.
|
|
response = hijacker.get(f"/api/session/{sid}/me")
|
|
assert response.status_code == 401
|
|
# And the cookie should be cleared so their browser bounces back to
|
|
# the join form rather than retrying with the dead cookie.
|
|
assert any(
|
|
h.lower() == "set-cookie" and "qz_student" in v and ("max-age=0" in v.lower() or "expires=" in v.lower())
|
|
for h, v in response.headers.items()
|
|
), response.headers
|
|
# Same goes for the audit-event endpoint.
|
|
response = hijacker.post(f"/api/session/{sid}/event", json={"kind": "blur"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_submit_accepts_option_text_resolves_to_canonical(client, sid):
|
|
"""The wire format is letterless: the student sends the option's
|
|
full text. Server resolves to the canonical letter for storage and
|
|
grading. CSV export shows the canonical position (1..4)."""
|
|
join_student(client, sid, "s1", "Alice")
|
|
rooms = client.app.state.rooms
|
|
client.portal.call(rooms.open_question, sid, 0, 5)
|
|
# Q0 in conftest: A=Alpha, B=Beta, C=Gamma, D=Delta, correct=B.
|
|
ack = client.portal.call(rooms.submit_answer, sid, "s1", 0, "Beta")
|
|
assert ack["type"] == "submit_ack"
|
|
assert ack["answer"] == "B" # server resolved to canonical letter
|
|
assert ack["score"] > 0
|
|
|
|
|
|
def test_submit_failsafe_locks_in_zero_score_on_garbage_text(client, sid):
|
|
"""Sending a string that isn't one of the four option texts records
|
|
a zero-score 'submitted' row and locks the student in (PK constraint
|
|
+ existing_submit_ack short-circuit). A second attempt — even with
|
|
the correct text — returns the original zero-score ack."""
|
|
join_student(client, sid, "s1", "Alice")
|
|
rooms = client.app.state.rooms
|
|
client.portal.call(rooms.open_question, sid, 0, 5)
|
|
first = client.portal.call(rooms.submit_answer, sid, "s1", 0, "not-an-option")
|
|
assert first["type"] == "submit_ack"
|
|
assert first["answer"] is None
|
|
assert first["score"] == 0
|
|
# Locked in: a follow-up retry returns the original zero ack.
|
|
second = client.portal.call(rooms.submit_answer, sid, "s1", 0, "Beta")
|
|
assert second["answer"] is None
|
|
assert second["score"] == 0
|
|
|
|
|
|
def test_submit_lockout_is_server_enforced(client, sid):
|
|
"""Server-side: a second submit for the same (sid, student_id, qidx)
|
|
returns the *original* ack rather than overwriting the answer. The
|
|
PK constraint + existing_submit_ack early-return guarantees this."""
|
|
join_student(client, sid, "s1", "Alice")
|
|
rooms = client.app.state.rooms
|
|
client.portal.call(rooms.open_question, sid, 0, 5)
|
|
first = client.portal.call(rooms.submit_answer, sid, "s1", 0, "B")
|
|
second = client.portal.call(rooms.submit_answer, sid, "s1", 0, "C")
|
|
assert first["type"] == "submit_ack"
|
|
assert second["type"] == "submit_ack"
|
|
assert second["answer"] == first["answer"] == "B"
|
|
assert second["score"] == first["score"]
|