Compare commits

...

10 Commits

Author SHA1 Message Date
ameer
7001a51803 deploy: add bootstrap.sh + Caddyfile + systemd unit + demo pool
One-shot deploy for fresh Ubuntu 24.04 root SSH:
  curl -fsSL https://gitea.ahkhan.me/apps/quiz/raw/branch/master/deploy/bootstrap.sh | bash

bootstrap.sh: idempotent stage-by-stage installer for Caddy, Python venv,
quiz system user, repo clone to /opt/quiz, env-var prompts, systemd unit,
Caddyfile, and a healthz check. Reattaches /dev/tty so curl|bash can read
the admin password interactively.

quiz.service: uvicorn under the quiz system user (no shell, no SSH),
ProtectSystem=full, ProtectHome=true, PrivateTmp=true, NoNewPrivileges=true.

Caddyfile.tpl: reverse_proxy 127.0.0.1:8001 with auto Let's Encrypt;
DOMAIN substituted at install time.

examples/pool_example.json: generic demo pool, schema reference only.

README rewritten around the deploy flow + class-day lifecycle.
2026-05-02 20:13:40 +08:00
ameer
0480d1528c chore(gitignore): exclude real quiz pools and codex build artifacts
Real pools contain answer keys; only the generic demo pool example is
allowed to be tracked. Also excludes the .codex_done / codex_run.log /
codex_last_message.md leftovers from the original codex build run.
2026-05-02 20:10:41 +08:00
ameer
bb070a688d fix(room): guard against non-dict WS payloads and unhashable answers
The first-pass JSON-decode hardening exposed two latent bugs that the
fuzz scenario hits as soon as the WS handler stays alive past a bad
message:

1) `data.get("type")` is called on whatever `receive_json()` decodes,
   but valid JSON can be a list/string/number, not just a dict. Reject
   non-object payloads with a structured bad_message error before
   dispatch.

2) `submit_answer` did `if answer not in {"A","B","C","D"}` which
   raises TypeError when the client sends an unhashable answer
   (e.g. a dict). Add an isinstance(str) guard so any non-string
   answer falls into the bad_answer branch instead of crashing the
   handler.

31/31 pytest still passes. Together with the prior commit, the WS
handlers now survive the full set of fuzz payloads without dropping
the connection.
2026-05-02 17:34:18 +08:00
ameer
b8e29e9b1e fix(room): widen WS handler exception scope to JSONDecodeError + RuntimeError
A single malformed JSON message (or a "WebSocket is not connected" race
on disconnect) was killing the per-client handler with an uncaught
exception in the ASGI app. The surrounding try/except only caught
WebSocketDisconnect, so the server would log a stack trace and the
client would silently drop.

Wrap receive_json() to catch JSONDecodeError, send a structured
{"type":"error","code":"bad_message"} ack, and continue. Widen the
outer except to (WebSocketDisconnect, RuntimeError) so disconnect
races on send/receive after close exit the handler cleanly instead
of bubbling up the ASGI stack.

Both student_ws and instructor_ws hardened in parallel. 31/31 pytest
suite still passes; this fixes the recurring fuzz-scenario warn and
the cycle-187-style cascade observed in the stress loop.
2026-05-02 17:31:25 +08:00
ameer
95a4dd2475 tests/stress: add Node-based adversarial stress harness
Two suites under tests/stress/, plus a tmux-friendly run_loop.sh
runner. Both boot a fresh uvicorn on an isolated DB per cycle and
log JSON line summaries to runs/.

api_stress.mjs covers WS-level scenarios that the existing pytest
suite does not exercise: 20-student happy path, late joiners with
correct remaining_ms, mid-question disconnect, browser-sleep + wake
to a different question_idx, cookie tampering and cross-session
cookie reuse, duplicate student_id, bad submit (out-of-order, wrong
idx, resubmit no-op), close-boundary race with auto-close, malformed
JSON fuzz, and flaky reconnect.

ui_stress.mjs drives the same flows in a real Chromium context via
playwright: happy UI, sleep/wake by closing+reopening a context with
the persisted cookie, document.cookie tampering attempt, and two
browser contexts joining with the same student_id.

Findings will be summarised in runs/summary.jsonl over time. One known
issue surfaces from the fuzz scenario: app/room.py student_ws's
receive_json call propagates JSONDecodeError out of the only
try/except (which catches WebSocketDisconnect), killing that client's
WS handler. Other clients are unaffected.
2026-05-02 15:26:18 +08:00
ameer
0f8824bd43 Add documentation and implementation report 2026-05-02 03:10:39 +08:00
ameer
63a03c0367 Add required test suite and websocket fixes 2026-05-02 03:08:48 +08:00
ameer
dfebfe2ee8 Add student and admin frontends 2026-05-02 03:02:08 +08:00
ameer
81e8173fb9 Add API routes and websocket room manager 2026-05-02 02:59:34 +08:00
ameer
a02f735c26 Add signed cookie auth 2026-05-02 02:59:34 +08:00
36 changed files with 3663 additions and 29 deletions

11
.gitignore vendored
View File

@@ -4,8 +4,19 @@ __pycache__/
.pytest_cache/
.coverage
htmlcov/
*.egg-info/
.env
quiz.db
*.db
*.db-shm
*.db-wal
# Real quiz pools must never be committed (they contain answer keys).
# Only generic demo pools tracked under examples/pool_example.json.
examples/*_pool.json
!examples/pool_example.json
# Codex build leftovers
.codex_done
codex_last_message.md
codex_run.log

67
IMPLEMENTATION_REPORT.md Normal file
View File

@@ -0,0 +1,67 @@
# Implementation Report
## Built
Implemented the live in-lecture quiz portal per `SPEC.md`: FastAPI backend, SQLite schema with WAL, signed student and admin cookies, quiz pool validation, modular scoring, student/admin APIs, WebSocket state machine, auto-close, CSV export, vanilla student/admin frontends, example Week 9 question pool, docs, and tests.
File inventory and line counts:
```text
app/: 1400 lines
static/: 775 lines
tests/: 504 lines
examples/week9_pool.json: 127 lines
total across app, static, tests, examples: 2806 lines
```
## Test Results
`pytest -q`:
```text
31 passed, 33 warnings in 5.43s
```
`pytest --cov=app`:
```text
31 passed, 33 warnings in 5.48s
TOTAL 854 statements, 67 missed, 92.15% coverage
Required test coverage of 80.0% reached.
```
Smoke test:
```text
uvicorn app.main:app started successfully on 127.0.0.1:8001
GET /healthz returned {"ok":true,"version":"0.1.0","sessions_active":0,"ws_clients":0}
```
## Run Locally
```bash
python3 -m venv .venv
. .venv/bin/activate
pip install -e '.[dev]'
cp .env.example .env
uvicorn app.main:app --host 127.0.0.1 --port 8001 --reload
```
## Deviations and Notes
- Server-side QR SVG data URLs are used instead of a client-side QR library.
- `live_histogram` is pushed on every accepted submission, not throttled.
- Broadcast sends are queued so slow WebSocket clients do not block state changes.
- `static/observer.html` is a placeholder because the observer page is optional.
- FastAPI emits a non-fatal `on_event` deprecation warning with the installed package version.
## Open Issues
No known functional blockers. The admin UI is intentionally plain and should be reviewed with the instructor workflow in mind before classroom use.
## Review Carefully
- The late-join behavior and missed-submission rows.
- The session control flow from `lobby` through `finished`.
- CSV shape against the exact spreadsheet format wanted for class records.
- The generated example questions, because they are plausible placeholders.

View File

@@ -1,3 +1,15 @@
# Notes
Implementation notes will be filled in as the application lands.
## Implementation Choices
- QR codes are generated server-side with the Python `qrcode` package and returned as an SVG data URL from session creation.
- `live_histogram` is pushed on every accepted submission. There is no throttling in v1 because the acceptance load is small and this keeps behavior simple.
- WebSocket broadcast sends are queued as background tasks. This prevents one slow classroom device from blocking state transitions or auto-close.
- The optional observer page exists as a placeholder only. The spec explicitly marks it optional.
- Tests use one FastAPI `TestClient` portal for multi-student WebSocket simulation. Each socket is opened after joining as that student, and the stored socket identity remains stable after the cookie is overwritten for the next simulated student.
- Python 3.14.4 was used locally. The project requires Python 3.11 or newer.
## Deviations
- The admin frontend includes a compact one-question sample in the Add Pool modal for convenience. The full 10-question Week 9 pool is in `examples/week9_pool.json`.
- FastAPI emits a deprecation warning for `on_event` under the installed version. Startup works correctly, and the warning is not user-facing.

View File

@@ -1,3 +1,77 @@
# Live In-Lecture Quiz Portal
# Live in-lecture quiz portal
Development notes will be filled in as the implementation lands.
FastAPI + WebSocket + SQLite quiz portal designed for ~40 students per
class session. Single-process, in-memory room manager, vanilla HTML/JS
front-end, Caddy in front for TLS.
## Quick local run
```bash
python3 -m venv .venv
. .venv/bin/activate
pip install -e '.[dev]'
cp .env.example .env # edit QUIZ_SECRET_KEY + QUIZ_ADMIN_PASSWORD
uvicorn app.main:app --host 127.0.0.1 --port 8001 --reload
```
Open `http://127.0.0.1:8001/admin/`, log in, create a quiz pool from a
JSON pool file (see `examples/pool_example.json` for the schema), create
a session, and share the join URL.
## VPS deploy (one-shot)
On a fresh Ubuntu 24.04 LTS root SSH:
```bash
curl -fsSL https://gitea.ahkhan.me/apps/quiz/raw/branch/master/deploy/bootstrap.sh | bash
```
The bootstrap:
1. apt-installs Caddy + Python venv tooling
2. Creates a `quiz` system user (no shell, no SSH)
3. Clones this repo to `/opt/quiz`
4. Builds the venv and installs the app
5. Generates `QUIZ_SECRET_KEY`, prompts for `QUIZ_ADMIN_PASSWORD`
6. Drops the systemd unit and Caddyfile
7. Starts both services
8. Curl-checks `127.0.0.1:8001/healthz`
After: `quiz.ahkhan.me` is live with auto-Let's-Encrypt cert. To override
the domain or repo URL, set `DOMAIN=` or `REPO_URL=` in the environment
before running the script.
## Class-day workflow
1. Provision Aliyun Intl HK ECS pay-as-you-go (`ecs.t6-c2m1.large`,
Ubuntu 24.04 LTS).
2. Point DNS A-record `quiz.ahkhan.me` at the new IP.
3. SSH in as root, run the curl|bash one-liner above.
4. Open `quiz.ahkhan.me/admin/`, log in, upload the week's pool JSON,
create a session.
5. Share the QR / join URL with the class.
6. After class:
`scp root@<ip>:/opt/quiz/quiz.db ./backups/quiz-YYYY-MM-DD.db`
7. Destroy the instance.
## Quiz pool files
Real pool JSON files contain answer keys and **must not be committed**
to this repo. `.gitignore` excludes `examples/*_pool.json` (only
`examples/pool_example.json` may be tracked). Author pools elsewhere
(e.g., your course-material directory) and upload at runtime via the
admin UI.
## Tests
```bash
pytest -q
pytest --cov=app
```
For the WebSocket adversarial stress harness (Node.js + Playwright,
runs in a tmux loop), see `tests/stress/README.md`.
## Spec
`SPEC.md` documents the locked v1.0 design (state machine, scoring,
identity flow, all WS message types).

View File

@@ -1 +1,107 @@
"""Cookie signing helpers."""
from __future__ import annotations
import secrets
import time
from typing import Any
from uuid import uuid4
from fastapi import HTTPException, Request, Response, WebSocket, status
from itsdangerous import BadSignature, URLSafeSerializer
from app.config import Settings
STUDENT_COOKIE = "qz_student"
ADMIN_COOKIE = "qz_admin"
STUDENT_MAX_AGE = 31_536_000
ADMIN_MAX_AGE = 86_400
def serializer(settings: Settings) -> URLSafeSerializer:
if not settings.secret_key:
raise HTTPException(status_code=500, detail="QUIZ_SECRET_KEY is not configured")
return URLSafeSerializer(settings.secret_key, salt="quiz-cookie-v1")
def sign_student(settings: Settings, sid: str, student_id: str, name: str, cookie_id: str | None = None) -> str:
payload = {
"sid": sid,
"student_id": student_id.strip(),
"name": name.strip(),
"cookie_id": cookie_id or str(uuid4()),
}
return serializer(settings).dumps(payload)
def sign_admin(settings: Settings) -> str:
return serializer(settings).dumps({"is_admin": True, "ts": int(time.time())})
def loads_cookie(settings: Settings, value: str | None) -> dict[str, Any] | None:
if not value:
return None
try:
payload = serializer(settings).loads(value)
except BadSignature:
return None
return payload if isinstance(payload, dict) else None
def get_student_identity(settings: Settings, request: Request, sid: str | None = None) -> dict[str, Any] | None:
payload = loads_cookie(settings, request.cookies.get(STUDENT_COOKIE))
if not payload:
return None
if sid is not None and payload.get("sid") != sid:
return None
required = {"sid", "student_id", "name", "cookie_id"}
return payload if required.issubset(payload) else None
def get_student_identity_ws(settings: Settings, websocket: WebSocket, sid: str) -> dict[str, Any] | None:
payload = loads_cookie(settings, websocket.cookies.get(STUDENT_COOKIE))
if not payload or payload.get("sid") != sid:
return None
required = {"sid", "student_id", "name", "cookie_id"}
return payload if required.issubset(payload) else None
def require_admin_request(settings: Settings, request: Request) -> None:
payload = loads_cookie(settings, request.cookies.get(ADMIN_COOKIE))
if not payload or payload.get("is_admin") is not True:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin login required")
def is_admin_ws(settings: Settings, websocket: WebSocket) -> bool:
payload = loads_cookie(settings, websocket.cookies.get(ADMIN_COOKIE))
return bool(payload and payload.get("is_admin") is True)
def verify_admin_password(settings: Settings, password: str) -> bool:
if not settings.admin_password:
return False
return secrets.compare_digest(password, settings.admin_password)
def set_student_cookie(settings: Settings, response: Response, value: str) -> None:
response.set_cookie(
STUDENT_COOKIE,
value,
max_age=STUDENT_MAX_AGE,
httponly=True,
samesite="lax",
secure=settings.secure_cookies,
path="/",
)
def set_admin_cookie(settings: Settings, response: Response, value: str) -> None:
response.set_cookie(
ADMIN_COOKIE,
value,
max_age=ADMIN_MAX_AGE,
httponly=True,
samesite="lax",
secure=settings.secure_cookies,
path="/",
)

View File

@@ -1 +1,658 @@
"""In-process WebSocket room manager."""
from __future__ import annotations
import asyncio
import json
from collections import defaultdict
from datetime import UTC, datetime
from typing import Any
from fastapi import WebSocket, WebSocketDisconnect
from app.config import Settings
from app.db import connect
from app.pool import get_question, parse_pool_json, public_question_payload, question_count, question_time_limit
from app.scoring import SCORE_FNS
def now_utc() -> datetime:
return datetime.now(UTC)
def now_ms() -> int:
return int(now_utc().timestamp() * 1000)
def iso_now() -> str:
return now_utc().isoformat()
def parse_ts(value: str) -> datetime:
if value.endswith("Z"):
value = value[:-1] + "+00:00"
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed
class RoomManager:
def __init__(self, settings: Settings):
self.settings = settings
self.student_clients: dict[str, dict[WebSocket, dict[str, Any]]] = defaultdict(dict)
self.instructor_clients: dict[str, set[WebSocket]] = defaultdict(set)
self.autoclose_tasks: dict[tuple[str, int], asyncio.Task] = {}
self.locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
async def sessions_active(self) -> int:
async with connect(self.settings.db_path) as db:
cursor = await db.execute("SELECT COUNT(*) AS count FROM quiz_sessions WHERE state != 'finished'")
row = await cursor.fetchone()
return int(row["count"])
def ws_client_count(self) -> int:
return sum(len(clients) for clients in self.student_clients.values()) + sum(
len(clients) for clients in self.instructor_clients.values()
)
async def add_participant(self, sid: str, student_id: str, name: str, cookie_id: str) -> None:
async with connect(self.settings.db_path) as db:
await db.execute(
"""
INSERT INTO participants (sid, student_id, name, cookie_id)
VALUES (?, ?, ?, ?)
ON CONFLICT(sid, student_id) DO UPDATE SET name = excluded.name, cookie_id = excluded.cookie_id
""",
(sid, student_id, name, cookie_id),
)
await db.execute(
"""
INSERT OR IGNORE INTO submissions (sid, student_id, question_idx, status, score)
SELECT sid, ?, question_idx, 'missed', 0
FROM question_events
WHERE sid = ? AND closed_at IS NOT NULL
""",
(student_id, sid),
)
await db.commit()
await self.broadcast_lobby(sid)
async def student_ws(self, websocket: WebSocket, sid: str, identity: dict[str, Any]) -> None:
await websocket.accept()
self.student_clients[sid][websocket] = identity
try:
await self.send_student_snapshot(websocket, sid, identity)
while True:
try:
data = await websocket.receive_json()
except json.JSONDecodeError:
try:
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Invalid JSON"})
except (WebSocketDisconnect, RuntimeError):
break
continue
if not isinstance(data, dict):
try:
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Message must be a JSON object"})
except (WebSocketDisconnect, RuntimeError):
break
continue
msg_type = data.get("type")
if msg_type == "ping":
await websocket.send_json({"type": "pong"})
elif msg_type == "submit":
ack = await self.submit_answer(sid, identity["student_id"], data.get("question_idx"), data.get("answer"))
await websocket.send_json(ack)
else:
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Unknown message type"})
except (WebSocketDisconnect, RuntimeError):
pass
finally:
self.student_clients[sid].pop(websocket, None)
async def instructor_ws(self, websocket: WebSocket, sid: str) -> None:
await websocket.accept()
self.instructor_clients[sid].add(websocket)
try:
await self.send_instructor_snapshot(websocket, sid)
while True:
try:
data = await websocket.receive_json()
except json.JSONDecodeError:
try:
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Invalid JSON"})
except (WebSocketDisconnect, RuntimeError):
break
continue
if not isinstance(data, dict):
try:
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Message must be a JSON object"})
except (WebSocketDisconnect, RuntimeError):
break
continue
msg_type = data.get("type")
if msg_type == "ping":
await websocket.send_json({"type": "pong"})
elif msg_type == "open_question":
await self.open_question(sid, int(data.get("question_idx", 0)), data.get("time_limit"))
elif msg_type == "close_question":
await self.close_question(sid)
elif msg_type == "next":
await self.next_question(sid)
elif msg_type == "end_session":
await self.end_session(sid)
else:
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Unknown message type"})
except (WebSocketDisconnect, RuntimeError):
pass
finally:
self.instructor_clients[sid].discard(websocket)
async def send_student_snapshot(self, websocket: WebSocket, sid: str, identity: dict[str, Any]) -> None:
session = await self.get_session(sid)
await websocket.send_json(
{
"type": "state",
"state": session["state"],
"current_question_idx": session["current_question_idx"],
"title": session["title"],
}
)
if session["state"] == "question_open":
await websocket.send_json(await self.question_open_message(sid, session["current_question_idx"]))
ack = await self.existing_submit_ack(sid, identity["student_id"], session["current_question_idx"])
if ack:
await websocket.send_json(ack)
async def send_instructor_snapshot(self, websocket: WebSocket, sid: str) -> None:
session = await self.get_session(sid)
await websocket.send_json(
{
"type": "state",
"state": session["state"],
"current_question_idx": session["current_question_idx"],
"title": session["title"],
}
)
await websocket.send_json(await self.lobby_message(sid))
if session["state"] == "question_open":
await websocket.send_json(await self.question_open_message(sid, session["current_question_idx"]))
await websocket.send_json(await self.live_histogram_message(sid, session["current_question_idx"]))
if session["state"] in {"question_closed", "between_questions", "finished"}:
await websocket.send_json(await self.full_leaderboard_message(sid))
async def open_question(self, sid: str, question_idx: int, time_limit: int | None = None) -> None:
async with self.locks[sid]:
session = await self.get_session(sid)
if session["state"] == "question_open" and session["current_question_idx"] == question_idx:
return
if session["state"] == "question_open":
await self._close_question_locked(sid, session["current_question_idx"])
pool = await self.get_pool_for_session(sid)
if question_idx < 0 or question_idx >= question_count(pool):
await self.broadcast_instructors(sid, {"type": "error", "code": "bad_question", "message": "Question index out of range"})
return
limit = int(time_limit or question_time_limit(pool, question_idx))
opened = iso_now()
async with connect(self.settings.db_path) as db:
await db.execute(
"""
INSERT INTO question_events (sid, question_idx, opened_at, time_limit)
VALUES (?, ?, ?, ?)
ON CONFLICT(sid, question_idx) DO UPDATE SET opened_at = excluded.opened_at,
closed_at = NULL, time_limit = excluded.time_limit
""",
(sid, question_idx, opened, limit),
)
await db.execute(
"UPDATE quiz_sessions SET state = 'question_open', current_question_idx = ? WHERE sid = ?",
(question_idx, sid),
)
await db.commit()
self._schedule_autoclose(sid, question_idx, limit)
msg = await self.question_open_message(sid, question_idx)
await self.broadcast_students(sid, msg)
await self.broadcast_instructors(sid, msg)
await self.broadcast_instructors(sid, await self.live_histogram_message(sid, question_idx))
async def close_question(self, sid: str) -> None:
async with self.locks[sid]:
session = await self.get_session(sid)
if session["state"] != "question_open":
return
question_idx = session["current_question_idx"]
await self._close_question_locked(sid, question_idx)
await self.broadcast_question_closed(sid, question_idx)
async def _close_question_locked(self, sid: str, question_idx: int) -> None:
task = self.autoclose_tasks.pop((sid, question_idx), None)
current = asyncio.current_task()
if task and task is not current:
task.cancel()
async with connect(self.settings.db_path) as db:
await db.execute(
"UPDATE question_events SET closed_at = COALESCE(closed_at, ?) WHERE sid = ? AND question_idx = ?",
(iso_now(), sid, question_idx),
)
await db.execute(
"""
INSERT OR IGNORE INTO submissions (sid, student_id, question_idx, status, score)
SELECT p.sid, p.student_id, ?, 'missed', 0
FROM participants p
WHERE p.sid = ?
""",
(question_idx, sid),
)
await db.execute(
"UPDATE quiz_sessions SET state = 'question_closed', current_question_idx = ? WHERE sid = ?",
(question_idx, sid),
)
await db.commit()
async def next_question(self, sid: str) -> None:
async with self.locks[sid]:
session = await self.get_session(sid)
if session["state"] != "question_closed":
return
next_idx = int(session["current_question_idx"]) + 1
async with connect(self.settings.db_path) as db:
await db.execute("UPDATE quiz_sessions SET state = 'between_questions' WHERE sid = ?", (sid,))
await db.commit()
await self.broadcast_between_questions(sid, next_idx)
async def end_session(self, sid: str) -> None:
async with self.locks[sid]:
session = await self.get_session(sid)
if session["state"] == "question_open":
await self._close_question_locked(sid, session["current_question_idx"])
async with connect(self.settings.db_path) as db:
await db.execute(
"UPDATE quiz_sessions SET state = 'finished', current_question_idx = NULL, finished_at = ? WHERE sid = ?",
(iso_now(), sid),
)
await db.commit()
await self.broadcast_session_ended(sid)
await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid))
async def submit_answer(self, sid: str, student_id: str, question_idx: Any, answer: Any) -> dict[str, Any]:
try:
qidx = int(question_idx)
except (TypeError, ValueError):
return {"type": "error", "code": "bad_question", "message": "Invalid question index"}
if not isinstance(answer, str) or answer not in {"A", "B", "C", "D"}:
return {"type": "error", "code": "bad_answer", "message": "Answer must be A, B, C, or D"}
async with self.locks[sid]:
session = await self.get_session(sid)
if session["state"] != "question_open" or session["current_question_idx"] != qidx:
return {"type": "error", "code": "not_open", "message": "Question is not open"}
existing = await self.existing_submit_ack(sid, student_id, qidx)
if existing:
return existing
event = await self.get_question_event(sid, qidx)
opened_at = parse_ts(event["opened_at"])
elapsed_ms = max(0, int((now_utc() - opened_at).total_seconds() * 1000))
time_limit_ms = int(event["time_limit"]) * 1000
if elapsed_ms > time_limit_ms:
return {"type": "error", "code": "time_expired", "message": "Question time has expired"}
pool = await self.get_pool_for_session(sid)
question = get_question(pool, qidx)
correct = answer == question["correct"]
score_fn = SCORE_FNS[pool["score_fn"]]
score = score_fn(correct, elapsed_ms, time_limit_ms)
submitted_at = iso_now()
async with connect(self.settings.db_path) as db:
await db.execute(
"""
INSERT INTO submissions (sid, student_id, question_idx, answer, submitted_at, elapsed_ms, score, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'submitted')
ON CONFLICT(sid, student_id, question_idx) DO NOTHING
""",
(sid, student_id, qidx, answer, submitted_at, elapsed_ms, score),
)
await db.commit()
await self.broadcast_instructors(sid, await self.live_histogram_message(sid, qidx))
return {"type": "submit_ack", "question_idx": qidx, "answer": answer, "score": score, "elapsed_ms": elapsed_ms}
def _schedule_autoclose(self, sid: str, question_idx: int, time_limit: int) -> None:
previous = self.autoclose_tasks.pop((sid, question_idx), None)
if previous:
previous.cancel()
self.autoclose_tasks[(sid, question_idx)] = asyncio.create_task(self._autoclose_after(sid, question_idx, time_limit))
async def _autoclose_after(self, sid: str, question_idx: int, time_limit: int) -> None:
try:
await asyncio.sleep(time_limit)
async with self.locks[sid]:
session = await self.get_session(sid)
if session["state"] == "question_open" and session["current_question_idx"] == question_idx:
await self._close_question_locked(sid, question_idx)
else:
return
await self.broadcast_question_closed(sid, question_idx)
except asyncio.CancelledError:
return
async def get_session(self, sid: str) -> dict[str, Any]:
async with connect(self.settings.db_path) as db:
cursor = await db.execute("SELECT * FROM quiz_sessions WHERE sid = ?", (sid,))
row = await cursor.fetchone()
if row is None:
raise KeyError(f"Unknown session {sid}")
return dict(row)
async def session_exists(self, sid: str) -> bool:
async with connect(self.settings.db_path) as db:
cursor = await db.execute("SELECT 1 FROM quiz_sessions WHERE sid = ?", (sid,))
row = await cursor.fetchone()
return row is not None
async def get_pool_for_session(self, sid: str) -> dict[str, Any]:
async with connect(self.settings.db_path) as db:
cursor = await db.execute(
"""
SELECT q.pool_json
FROM quizzes q
JOIN quiz_sessions s ON s.quiz_id = q.id
WHERE s.sid = ?
""",
(sid,),
)
row = await cursor.fetchone()
if row is None:
raise KeyError(f"Unknown session {sid}")
return parse_pool_json(row["pool_json"])
async def get_question_event(self, sid: str, question_idx: int) -> dict[str, Any]:
async with connect(self.settings.db_path) as db:
cursor = await db.execute(
"SELECT * FROM question_events WHERE sid = ? AND question_idx = ?",
(sid, question_idx),
)
row = await cursor.fetchone()
if row is None:
raise KeyError("Question has not been opened")
return dict(row)
async def existing_submit_ack(self, sid: str, student_id: str, question_idx: int) -> dict[str, Any] | None:
async with connect(self.settings.db_path) as db:
cursor = await db.execute(
"""
SELECT answer, elapsed_ms, score, status
FROM submissions
WHERE sid = ? AND student_id = ? AND question_idx = ? AND status = 'submitted'
""",
(sid, student_id, question_idx),
)
row = await cursor.fetchone()
if row is None:
return None
return {
"type": "submit_ack",
"question_idx": question_idx,
"answer": row["answer"],
"score": row["score"],
"elapsed_ms": row["elapsed_ms"],
}
async def question_open_message(self, sid: str, question_idx: int) -> dict[str, Any]:
pool = await self.get_pool_for_session(sid)
event = await self.get_question_event(sid, question_idx)
opened_ms = int(parse_ts(event["opened_at"]).timestamp() * 1000)
remaining_ms = max(0, opened_ms + int(event["time_limit"]) * 1000 - now_ms())
msg = {"type": "question_open", **public_question_payload(pool, question_idx)}
msg["opened_at_server_ts"] = opened_ms
msg["remaining_ms"] = remaining_ms
return msg
async def question_closed_message(self, sid: str, question_idx: int, identity: dict[str, Any] | None = None) -> dict[str, Any]:
pool = await self.get_pool_for_session(sid)
question = get_question(pool, question_idx)
msg = {
"type": "question_closed",
"question_idx": question_idx,
"correct": question["correct"],
"explanation": question.get("explanation", ""),
"histogram": await self.histogram(sid, question_idx),
"top5": await self.leaderboard(sid, limit=5),
}
if identity:
student = identity["student_id"]
submission = await self.submission_for(sid, student, question_idx)
rank = await self.rank_for(sid, student)
total = await self.total_for(sid, student)
msg.update(
{
"your_answer": submission.get("answer") if submission else None,
"your_score": submission.get("score", 0) if submission else 0,
"your_rank": rank,
"your_total": total,
}
)
return msg
async def between_message(self, sid: str, next_idx: int, identity: dict[str, Any] | None = None) -> dict[str, Any]:
msg = {"type": "between_questions", "next_idx": next_idx, "top5": await self.leaderboard(sid, limit=5)}
if identity:
msg["your_rank"] = await self.rank_for(sid, identity["student_id"])
msg["your_total"] = await self.total_for(sid, identity["student_id"])
return msg
async def ended_message(self, sid: str, identity: dict[str, Any] | None = None) -> dict[str, Any]:
msg = {"type": "session_ended", "final_top5": await self.leaderboard(sid, limit=5)}
if identity:
student = identity["student_id"]
msg.update(await self.student_summary(sid, student))
msg["your_rank"] = await self.rank_for(sid, student)
msg["your_total"] = await self.total_for(sid, student)
return msg
async def histogram(self, sid: str, question_idx: int, pending: bool = False) -> dict[str, int]:
result = {"A": 0, "B": 0, "C": 0, "D": 0, "missed": 0}
async with connect(self.settings.db_path) as db:
cursor = await db.execute(
"SELECT answer, status, COUNT(*) AS count FROM submissions WHERE sid = ? AND question_idx = ? GROUP BY answer, status",
(sid, question_idx),
)
rows = await cursor.fetchall()
total_cursor = await db.execute("SELECT COUNT(*) AS count FROM participants WHERE sid = ?", (sid,))
total_row = await total_cursor.fetchone()
submitted = 0
for row in rows:
if row["status"] == "missed":
result["missed"] += row["count"]
elif row["answer"] in result:
result[row["answer"]] += row["count"]
submitted += row["count"]
if pending:
result["pending"] = max(0, int(total_row["count"]) - submitted - result["missed"])
return result
async def leaderboard(self, sid: str, limit: int | None = None, include_ids: bool = False) -> list[dict[str, Any]]:
query_limit = "" if limit is None else f"LIMIT {int(limit)}"
async with connect(self.settings.db_path) as db:
cursor = await db.execute(
f"""
SELECT p.student_id, p.name, COALESCE(SUM(s.score), 0) AS score
FROM participants p
LEFT JOIN submissions s ON s.sid = p.sid AND s.student_id = p.student_id
WHERE p.sid = ?
GROUP BY p.student_id, p.name
ORDER BY score DESC, p.name ASC, p.student_id ASC
{query_limit}
""",
(sid,),
)
rows = await cursor.fetchall()
board = []
for rank, row in enumerate(rows, start=1):
item = {"rank": rank, "name": row["name"], "score": int(row["score"])}
if include_ids:
item["student_id"] = row["student_id"]
board.append(item)
return board
async def rank_for(self, sid: str, student_id: str) -> int | None:
board = await self.leaderboard(sid, include_ids=True)
for item in board:
if item["student_id"] == student_id:
return item["rank"]
return None
async def total_for(self, sid: str, student_id: str) -> int:
async with connect(self.settings.db_path) as db:
cursor = await db.execute(
"SELECT COALESCE(SUM(score), 0) AS total FROM submissions WHERE sid = ? AND student_id = ?",
(sid, student_id),
)
row = await cursor.fetchone()
return int(row["total"])
async def submission_for(self, sid: str, student_id: str, question_idx: int) -> dict[str, Any] | None:
async with connect(self.settings.db_path) as db:
cursor = await db.execute(
"SELECT * FROM submissions WHERE sid = ? AND student_id = ? AND question_idx = ?",
(sid, student_id, question_idx),
)
row = await cursor.fetchone()
return dict(row) if row else None
async def student_summary(self, sid: str, student_id: str) -> dict[str, int]:
pool = await self.get_pool_for_session(sid)
async with connect(self.settings.db_path) as db:
cursor = await db.execute(
"SELECT question_idx, answer, status FROM submissions WHERE sid = ? AND student_id = ?",
(sid, student_id),
)
rows = await cursor.fetchall()
answered = sum(1 for row in rows if row["status"] == "submitted")
correct = 0
for row in rows:
if row["status"] == "submitted" and row["answer"] == get_question(pool, row["question_idx"])["correct"]:
correct += 1
return {"questions_answered": answered, "questions_correct": correct}
async def lobby_message(self, sid: str) -> dict[str, Any]:
async with connect(self.settings.db_path) as db:
cursor = await db.execute(
"SELECT student_id, name, joined_at FROM participants WHERE sid = ? ORDER BY joined_at, name",
(sid,),
)
rows = await cursor.fetchall()
participants = [dict(row) for row in rows]
return {"type": "lobby_update", "participants": participants, "count": len(participants)}
async def live_histogram_message(self, sid: str, question_idx: int) -> dict[str, Any]:
histogram = await self.histogram(sid, question_idx, pending=True)
submitted_count = histogram["A"] + histogram["B"] + histogram["C"] + histogram["D"]
return {
"type": "live_histogram",
"question_idx": question_idx,
"histogram": histogram,
"submitted_count": submitted_count,
"total_count": submitted_count + histogram["missed"] + histogram.get("pending", 0),
}
async def full_leaderboard_message(self, sid: str) -> dict[str, Any]:
return {"type": "full_leaderboard", "leaderboard": await self.leaderboard(sid, include_ids=True)}
async def me(self, sid: str, student_id: str) -> dict[str, Any]:
async with connect(self.settings.db_path) as db:
part_cursor = await db.execute("SELECT * FROM participants WHERE sid = ? AND student_id = ?", (sid, student_id))
participant = await part_cursor.fetchone()
sub_cursor = await db.execute(
"SELECT question_idx, answer, elapsed_ms, score, status FROM submissions WHERE sid = ? AND student_id = ? ORDER BY question_idx",
(sid, student_id),
)
submissions = await sub_cursor.fetchall()
return {
"student_id": participant["student_id"],
"name": participant["name"],
"total_score": await self.total_for(sid, student_id),
"submissions": [dict(row) for row in submissions],
}
async def stats(self, sid: str, question_idx: int | None, student_id: str | None = None) -> dict[str, Any]:
session = await self.get_session(sid)
qidx = question_idx if question_idx is not None else session["current_question_idx"]
if qidx is None:
return {
"question_idx": None,
"response_time_avg_ms": None,
"response_time_distribution": {},
"average_score": 0,
"top5": await self.leaderboard(sid, limit=5),
"your_rank": None,
}
async with connect(self.settings.db_path) as db:
cursor = await db.execute(
"""
SELECT elapsed_ms, score
FROM submissions
WHERE sid = ? AND question_idx = ? AND status = 'submitted'
""",
(sid, qidx),
)
rows = await cursor.fetchall()
times = [row["elapsed_ms"] for row in rows if row["elapsed_ms"] is not None]
scores = [row["score"] for row in rows]
distribution = {"0_10s": 0, "10_30s": 0, "30s_plus": 0}
for elapsed in times:
if elapsed < 10_000:
distribution["0_10s"] += 1
elif elapsed < 30_000:
distribution["10_30s"] += 1
else:
distribution["30s_plus"] += 1
payload = {
"question_idx": qidx,
"response_time_avg_ms": round(sum(times) / len(times)) if times else None,
"response_time_distribution": distribution,
"average_score": round(sum(scores) / len(scores), 2) if scores else 0,
"top5": await self.leaderboard(sid, limit=5),
}
if student_id:
payload["your_rank"] = await self.rank_for(sid, student_id)
return payload
async def broadcast_lobby(self, sid: str) -> None:
await self.broadcast_instructors(sid, await self.lobby_message(sid))
async def broadcast_question_closed(self, sid: str, question_idx: int) -> None:
for websocket, identity in list(self.student_clients[sid].items()):
self._queue_send(websocket, await self.question_closed_message(sid, question_idx, identity))
await self.broadcast_instructors(sid, await self.question_closed_message(sid, question_idx))
await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid))
await asyncio.sleep(0)
async def broadcast_between_questions(self, sid: str, next_idx: int) -> None:
for websocket, identity in list(self.student_clients[sid].items()):
self._queue_send(websocket, await self.between_message(sid, next_idx, identity))
await self.broadcast_instructors(sid, await self.between_message(sid, next_idx))
await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid))
await asyncio.sleep(0)
async def broadcast_session_ended(self, sid: str) -> None:
for websocket, identity in list(self.student_clients[sid].items()):
self._queue_send(websocket, await self.ended_message(sid, identity))
await self.broadcast_instructors(sid, await self.ended_message(sid))
await asyncio.sleep(0)
async def broadcast_students(self, sid: str, message: dict[str, Any]) -> None:
for websocket in list(self.student_clients[sid]):
self._queue_send(websocket, message)
await asyncio.sleep(0)
async def broadcast_instructors(self, sid: str, message: dict[str, Any]) -> None:
for websocket in list(self.instructor_clients[sid]):
self._queue_send(websocket, message)
await asyncio.sleep(0)
def _queue_send(self, websocket: WebSocket, message: dict[str, Any]) -> None:
asyncio.create_task(self._safe_send(websocket, message))
async def _safe_send(self, websocket: WebSocket, message: dict[str, Any]) -> None:
try:
await websocket.send_text(json.dumps(message))
except Exception:
pass

View File

@@ -1 +1,177 @@
"""Instructor routes."""
from __future__ import annotations
import base64
import json
import secrets
from io import BytesIO
from typing import Any
import qrcode
import qrcode.image.svg
from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFile, WebSocket
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
from app import auth
from app.config import Settings
from app.csv_export import export_session_csv
from app.db import connect
from app.models import QuizCreateRequest, SessionCreateRequest
from app.pool import PoolValidationError, parse_pool_json
from app.room import RoomManager
CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
def router(settings: Settings, rooms: RoomManager) -> APIRouter:
api = APIRouter()
def require_admin(request: Request) -> None:
auth.require_admin_request(settings, request)
@api.get("/admin/login")
async def login_form():
return HTMLResponse(
"<!doctype html><title>Admin Login</title><form method='post'>"
"<label>Password <input name='password' type='password'></label>"
"<button type='submit'>Log in</button></form>"
)
@api.post("/admin/login")
async def login(request: Request, response: Response):
password = ""
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
data = await request.json()
password = str(data.get("password", ""))
else:
form = await request.form()
password = str(form.get("password", ""))
if not auth.verify_admin_password(settings, password):
raise HTTPException(status_code=401, detail="Invalid admin password")
auth.set_admin_cookie(settings, response, auth.sign_admin(settings))
return {"ok": True}
@api.get("/admin/")
async def admin_page(request: Request):
require_admin(request)
return FileResponse("static/admin.html")
@api.get("/admin/api/quizzes")
async def list_quizzes(request: Request):
require_admin(request)
async with connect(settings.db_path) as db:
cursor = await db.execute(
"SELECT id, title, time_limit_default, score_fn_name, created_at FROM quizzes ORDER BY created_at DESC, id DESC"
)
rows = await cursor.fetchall()
return {"quizzes": [dict(row) for row in rows]}
@api.post("/admin/api/quizzes")
async def create_quiz(request: Request, body: QuizCreateRequest):
require_admin(request)
try:
pool = parse_pool_json(body.pool_json)
except PoolValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if body.time_limit_default is not None:
pool["time_limit_default"] = body.time_limit_default
title = (body.title or pool["title"]).strip()
async with connect(settings.db_path) as db:
cursor = await db.execute(
"INSERT INTO quizzes (title, pool_json, time_limit_default, score_fn_name) VALUES (?, ?, ?, ?)",
(title, json.dumps(pool), pool["time_limit_default"], pool["score_fn"]),
)
await db.commit()
quiz_id = cursor.lastrowid
return {"ok": True, "quiz_id": quiz_id}
@api.post("/admin/api/quizzes/upload")
async def upload_quiz(request: Request, file: UploadFile = File(...)):
require_admin(request)
raw = (await file.read()).decode("utf-8")
try:
pool = parse_pool_json(raw)
except PoolValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
async with connect(settings.db_path) as db:
cursor = await db.execute(
"INSERT INTO quizzes (title, pool_json, time_limit_default, score_fn_name) VALUES (?, ?, ?, ?)",
(pool["title"], json.dumps(pool), pool["time_limit_default"], pool["score_fn"]),
)
await db.commit()
quiz_id = cursor.lastrowid
return {"ok": True, "quiz_id": quiz_id}
@api.get("/admin/api/sessions")
async def list_sessions(request: Request):
require_admin(request)
async with connect(settings.db_path) as db:
cursor = await db.execute(
"""
SELECT s.sid, s.quiz_id, s.title, s.state, s.current_question_idx, s.started_at, s.finished_at,
COUNT(p.student_id) AS participant_count
FROM quiz_sessions s
LEFT JOIN participants p ON p.sid = s.sid
GROUP BY s.sid
ORDER BY s.started_at DESC
"""
)
rows = await cursor.fetchall()
return {"sessions": [dict(row) for row in rows]}
@api.post("/admin/api/sessions")
async def create_session(request: Request, body: SessionCreateRequest):
require_admin(request)
async with connect(settings.db_path) as db:
quiz_cursor = await db.execute("SELECT id, title FROM quizzes WHERE id = ?", (body.quiz_id,))
quiz = await quiz_cursor.fetchone()
if quiz is None:
raise HTTPException(status_code=404, detail="Quiz not found")
sid = await _generate_sid(db)
await db.execute(
"INSERT INTO quiz_sessions (sid, quiz_id, title) VALUES (?, ?, ?)",
(sid, body.quiz_id, quiz["title"]),
)
await db.commit()
join_url = f"{settings.public_url}/?sid={sid}"
return {"sid": sid, "join_url": join_url, "qr_url": _qr_data_url(join_url)}
@api.get("/admin/api/sessions/{sid}/csv")
async def csv_download(sid: str, request: Request):
require_admin(request)
if not await rooms.session_exists(sid):
raise HTTPException(status_code=404, detail="Session not found")
csv_text = await export_session_csv(settings.db_path, sid)
return PlainTextResponse(
csv_text,
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{sid}-results.csv"'},
)
@api.websocket("/ws/instructor/{sid}")
async def instructor_socket(websocket: WebSocket, sid: str):
if not auth.is_admin_ws(settings, websocket) or not await rooms.session_exists(sid):
await websocket.close(code=4001)
return
await rooms.instructor_ws(websocket, sid)
return api
async def _generate_sid(db: Any) -> str:
for _ in range(5):
sid = "".join(secrets.choice(CROCKFORD) for _ in range(6))
cursor = await db.execute("SELECT 1 FROM quiz_sessions WHERE sid = ?", (sid,))
if await cursor.fetchone() is None:
return sid
raise HTTPException(status_code=500, detail="Could not allocate session ID")
def _qr_data_url(value: str) -> str:
image = qrcode.make(value, image_factory=qrcode.image.svg.SvgPathImage)
buf = BytesIO()
image.save(buf)
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:image/svg+xml;base64,{encoded}"

View File

@@ -1 +1,75 @@
"""Student routes."""
from __future__ import annotations
from pathlib import Path
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket
from fastapi.responses import FileResponse, HTMLResponse
from app import auth
from app.config import Settings
from app.models import JoinRequest
from app.room import RoomManager
def router(settings: Settings, rooms: RoomManager) -> APIRouter:
api = APIRouter()
@api.get("/")
async def student_entry(sid: str | None = None):
if not sid or not await rooms.session_exists(sid):
return HTMLResponse(
"<!doctype html><title>Quiz</title><main><h1>Ask your instructor for the link</h1>"
"<p>This quiz link is missing or no longer valid.</p></main>"
)
return FileResponse(Path("static/student.html"))
@api.get("/api/session/{sid}")
async def session_metadata(sid: str):
if not await rooms.session_exists(sid):
raise HTTPException(status_code=404, detail="Session not found")
session = await rooms.get_session(sid)
return {
"title": session["title"],
"state": session["state"],
"current_question_idx": session["current_question_idx"],
"time_limit_default": (await rooms.get_pool_for_session(sid))["time_limit_default"],
}
@api.post("/api/session/{sid}/join")
async def join_session(sid: str, body: JoinRequest, response: Response):
if not await rooms.session_exists(sid):
raise HTTPException(status_code=404, detail="Session not found")
student_id = body.student_id.strip()
name = body.name.strip()
cookie_id = str(uuid4())
cookie_value = auth.sign_student(settings, sid, student_id, name, cookie_id)
await rooms.add_participant(sid, student_id, name, cookie_id)
auth.set_student_cookie(settings, response, cookie_value)
return {"ok": True, "cookie_id": cookie_id}
@api.get("/api/session/{sid}/me")
async def me(sid: str, request: Request):
identity = auth.get_student_identity(settings, request, sid)
if not identity:
raise HTTPException(status_code=401, detail="Student cookie required")
return await rooms.me(sid, identity["student_id"])
@api.get("/api/session/{sid}/stats")
async def stats(sid: str, request: Request, question_idx: int | None = None):
if not await rooms.session_exists(sid):
raise HTTPException(status_code=404, detail="Session not found")
identity = auth.get_student_identity(settings, request, sid)
return await rooms.stats(sid, question_idx, identity["student_id"] if identity else None)
@api.websocket("/ws/student/{sid}")
async def student_socket(websocket: WebSocket, sid: str):
identity = auth.get_student_identity_ws(settings, websocket, sid)
if not identity or not await rooms.session_exists(sid):
await websocket.close(code=4001)
return
await rooms.student_ws(websocket, sid, identity)
return api

4
deploy/Caddyfile.tpl Normal file
View File

@@ -0,0 +1,4 @@
__DOMAIN__ {
encode gzip
reverse_proxy 127.0.0.1:8001
}

114
deploy/bootstrap.sh Executable file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Live in-lecture quiz portal — VPS bootstrap.
# Idempotent: safe to re-run on a partially-bootstrapped host.
# Designed for: fresh Ubuntu 24.04 LTS, run as root.
#
# Usage (one-shot, on the VPS):
# curl -fsSL https://gitea.ahkhan.me/apps/quiz/raw/branch/master/deploy/bootstrap.sh | bash
#
# Override via env:
# DOMAIN=quiz.example.org curl ... | bash
# REPO_URL=https://... curl ... | bash
set -euo pipefail
# When invoked through curl|bash, stdin is the pipe, not the TTY.
# Reattach TTY so `read -s` works for the password prompt.
[ -t 0 ] || exec < /dev/tty
REPO_URL="${REPO_URL:-https://gitea.ahkhan.me/apps/quiz.git}"
APP_DIR="${APP_DIR:-/opt/quiz}"
APP_USER="${APP_USER:-quiz}"
DOMAIN="${DOMAIN:-quiz.ahkhan.me}"
BRANCH="${BRANCH:-master}"
if [ "$(id -u)" != "0" ]; then
echo "bootstrap.sh must run as root" >&2
exit 1
fi
stage() { printf '\n==> Stage %s\n' "$*"; }
stage "1/8: apt update + base packages"
apt-get update -q
DEBIAN_FRONTEND=noninteractive apt-get install -y -q \
git curl ca-certificates gnupg \
python3 python3-venv python3-pip \
debian-keyring debian-archive-keyring apt-transport-https
stage "2/8: install Caddy (skip if present)"
if ! command -v caddy >/dev/null 2>&1; then
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null
apt-get update -q
apt-get install -y -q caddy
fi
stage "3/8: create $APP_USER system user (skip if present)"
if ! id "$APP_USER" >/dev/null 2>&1; then
useradd --system --shell /usr/sbin/nologin --home-dir "$APP_DIR" "$APP_USER"
fi
stage "4/8: clone or update repo into $APP_DIR"
if [ -d "$APP_DIR/.git" ]; then
git -C "$APP_DIR" fetch origin
git -C "$APP_DIR" reset --hard "origin/$BRANCH"
else
rm -rf "$APP_DIR"
git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
fi
chown -R "$APP_USER":"$APP_USER" "$APP_DIR"
stage "5/8: build venv + install dependencies"
sudo -u "$APP_USER" -H python3 -m venv "$APP_DIR/.venv"
sudo -u "$APP_USER" -H "$APP_DIR/.venv/bin/pip" install --quiet --upgrade pip
sudo -u "$APP_USER" -H "$APP_DIR/.venv/bin/pip" install --quiet -e "$APP_DIR"
stage "6/8: configure environment (.env)"
ENV_FILE="$APP_DIR/.env"
if [ ! -f "$ENV_FILE" ]; then
if [ -f /root/.quiz.env ]; then
echo "Using /root/.quiz.env"
cp /root/.quiz.env "$ENV_FILE"
else
QUIZ_SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))')
printf 'Admin password (input hidden): '
read -rs QUIZ_ADMIN_PASSWORD
echo
cat > "$ENV_FILE" <<EOF
QUIZ_DB_PATH=$APP_DIR/quiz.db
QUIZ_SECRET_KEY=$QUIZ_SECRET_KEY
QUIZ_ADMIN_PASSWORD=$QUIZ_ADMIN_PASSWORD
QUIZ_HOST=127.0.0.1
QUIZ_PORT=8001
QUIZ_PUBLIC_URL=https://$DOMAIN
QUIZ_LOG_LEVEL=INFO
EOF
fi
chown "$APP_USER":"$APP_USER" "$ENV_FILE"
chmod 600 "$ENV_FILE"
fi
stage "7/8: install systemd unit"
install -m 644 "$APP_DIR/deploy/quiz.service" /etc/systemd/system/quiz.service
systemctl daemon-reload
systemctl enable --now quiz.service
stage "8/8: configure Caddy"
sed "s/__DOMAIN__/$DOMAIN/g" "$APP_DIR/deploy/Caddyfile.tpl" > /etc/caddy/Caddyfile
systemctl reload caddy
echo
echo "==> Health check"
sleep 2
if curl -fs http://127.0.0.1:8001/healthz; then
echo
echo
echo "Bootstrap complete. Public URL: https://$DOMAIN"
else
echo
echo "Health check failed. Inspect: journalctl -u quiz.service -n 50"
exit 1
fi

21
deploy/quiz.service Normal file
View File

@@ -0,0 +1,21 @@
[Unit]
Description=Live in-lecture quiz portal (uvicorn)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=quiz
Group=quiz
WorkingDirectory=/opt/quiz
EnvironmentFile=/opt/quiz/.env
ExecStart=/opt/quiz/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8001 --no-access-log
Restart=on-failure
RestartSec=2
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,31 @@
{
"title": "Demo Pool: Generic Knowledge",
"score_fn": "linear_decay",
"time_limit_default": 60,
"questions": [
{
"id": "demo1",
"text": "Which of these is a programming language?",
"options": {
"A": "HTTP",
"B": "Python",
"C": "TCP",
"D": "DNS"
},
"correct": "B",
"explanation": "Python is a general-purpose programming language; the others are network protocols."
},
{
"id": "demo2",
"text": "What is 2 + 2?",
"options": {
"A": "3",
"B": "4",
"C": "5",
"D": "22"
},
"correct": "B",
"explanation": "Basic arithmetic."
}
]
}

View File

@@ -1,2 +1,212 @@
const app = document.querySelector("#admin-app");
app.textContent = "Loading admin...";
let quizzes = [];
let sessions = [];
let activeSid = null;
let ws = null;
let leaderboard = [];
let roster = [];
let liveHistogram = null;
let currentState = null;
const samplePool = {
title: "Week 9 Recap: Computer Organization",
score_fn: "linear_decay",
time_limit_default: 60,
questions: [
{id: "q1", text: "Which unit sequences control signals in a multi-cycle datapath?", options: {A: "ALU", B: "Control unit", C: "Register file", D: "Instruction memory"}, correct: "B"}
]
};
function escapeText(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[char]);
}
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: "same-origin",
headers: {"Content-Type": "application/json", ...(options.headers || {})},
...options,
});
if (!response.ok) throw new Error(await response.text());
const contentType = response.headers.get("content-type") || "";
return contentType.includes("json") ? response.json() : response.text();
}
async function boot() {
try {
await refresh();
render();
} catch {
renderLogin();
}
}
function renderLogin(error = "") {
app.innerHTML = `<section class="shell"><div class="panel narrow">
<h1>Admin Login</h1>
<form id="login-form" class="stack">
<label>Password <input name="password" type="password" required></label>
${error ? `<p class="error">${escapeText(error)}</p>` : ""}
<button class="primary">Log in</button>
</form>
</div></section>`;
document.querySelector("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
await api("/admin/login", {method: "POST", body: JSON.stringify({password: form.get("password")})});
await refresh();
render();
} catch {
renderLogin("Login failed.");
}
});
}
async function refresh() {
quizzes = (await api("/admin/api/quizzes")).quizzes;
sessions = (await api("/admin/api/sessions")).sessions;
}
function render() {
app.innerHTML = `<section class="admin-layout">
<aside class="sidebar">
<h1>Quiz Admin</h1>
<button id="new-quiz" class="secondary">Add Pool</button>
<button id="new-session" class="primary" ${quizzes.length ? "" : "disabled"}>Create Session</button>
<h2>Quizzes</h2>
<div class="list">${quizzes.map((quiz) => `<button data-quiz="${quiz.id}">${escapeText(quiz.title)}</button>`).join("") || "<p>No quizzes yet.</p>"}</div>
<h2>Sessions</h2>
<div class="list">${sessions.map((session) => `<button data-session="${session.sid}">${session.sid} ${escapeText(session.state)}</button>`).join("") || "<p>No sessions yet.</p>"}</div>
</aside>
<main class="workspace">${renderSession()}</main>
</section>`;
document.querySelector("#new-quiz").addEventListener("click", renderQuizModal);
document.querySelector("#new-session").addEventListener("click", renderSessionModal);
document.querySelectorAll("[data-session]").forEach((button) => {
button.addEventListener("click", () => connectSession(button.dataset.session));
});
bindControls();
}
function renderSession() {
if (!activeSid) return `<div class="panel"><h1>No active session</h1><p>Create or select a session.</p></div>`;
const session = sessions.find((item) => item.sid === activeSid);
return `<div class="panel">
<div class="topline"><h1>${escapeText(session?.title || activeSid)}</h1><span>${escapeText(currentState?.state || session?.state || "")}</span></div>
<p>Session ID: <strong>${activeSid}</strong></p>
<div class="toolbar">
<button data-command="open_question" class="primary">Open</button>
<button data-command="close_question">Close &amp; Reveal</button>
<button data-command="next">Next</button>
<button data-command="end_session" class="danger">End</button>
<a class="button" href="/admin/api/sessions/${activeSid}/csv">Download CSV</a>
</div>
<label>Question index <input id="question-idx" type="number" min="0" value="${currentState?.current_question_idx ?? 0}"></label>
<label>Time limit <input id="time-limit" type="number" min="1" value="60"></label>
<h2>Roster (${roster.length})</h2>
<div class="roster">${roster.map((p) => `<span>${escapeText(p.student_id)} ${escapeText(p.name)}</span>`).join("") || "No students yet."}</div>
<h2>Live Histogram</h2>
${renderHistogram(liveHistogram?.histogram)}
<h2>Leaderboard</h2>
${renderLeaderboard(leaderboard)}
</div>`;
}
function renderHistogram(histogram) {
const data = histogram || {A: 0, B: 0, C: 0, D: 0, missed: 0, pending: 0};
return `<div class="histogram">${Object.entries(data).map(([key, value]) => (
`<div class="hist-row"><span>${key}</span><meter min="0" max="50" value="${value}"></meter><b>${value}</b></div>`
)).join("")}</div>`;
}
function renderLeaderboard(rows) {
return `<ol class="leaderboard">${(rows || []).map((row) => (
`<li><span>${row.rank}. ${escapeText(row.name)} ${row.student_id ? `(${escapeText(row.student_id)})` : ""}</span><strong>${row.score}</strong></li>`
)).join("") || "<li>No scores yet.</li>"}</ol>`;
}
function bindControls() {
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const command = button.dataset.command;
if (command === "open_question") {
ws.send(JSON.stringify({
type: command,
question_idx: Number(document.querySelector("#question-idx").value || 0),
time_limit: Number(document.querySelector("#time-limit").value || 60),
}));
} else {
ws.send(JSON.stringify({type: command}));
}
});
});
}
function renderQuizModal() {
app.innerHTML = `<section class="shell"><div class="panel">
<h1>Add Pool</h1>
<form id="quiz-form" class="stack">
<label>Pool JSON <textarea name="pool" rows="18">${escapeText(JSON.stringify(samplePool, null, 2))}</textarea></label>
<button class="primary">Create Quiz</button>
<button type="button" id="cancel">Cancel</button>
</form>
</div></section>`;
document.querySelector("#cancel").addEventListener("click", render);
document.querySelector("#quiz-form").addEventListener("submit", async (event) => {
event.preventDefault();
const pool = JSON.parse(new FormData(event.currentTarget).get("pool"));
await api("/admin/api/quizzes", {method: "POST", body: JSON.stringify({pool_json: pool})});
await refresh();
render();
});
}
async function renderSessionModal() {
const options = quizzes.map((quiz) => `<option value="${quiz.id}">${escapeText(quiz.title)}</option>`).join("");
app.innerHTML = `<section class="shell"><div class="panel narrow">
<h1>Create Session</h1>
<form id="session-form" class="stack">
<label>Quiz <select name="quiz_id">${options}</select></label>
<button class="primary">Create</button>
<button type="button" id="cancel">Cancel</button>
</form>
<div id="session-result"></div>
</div></section>`;
document.querySelector("#cancel").addEventListener("click", render);
document.querySelector("#session-form").addEventListener("submit", async (event) => {
event.preventDefault();
const quizId = Number(new FormData(event.currentTarget).get("quiz_id"));
const result = await api("/admin/api/sessions", {method: "POST", body: JSON.stringify({quiz_id: quizId})});
document.querySelector("#session-result").innerHTML = `<h2>${result.sid}</h2><p><a href="${result.join_url}">${result.join_url}</a></p><img class="qr" src="${result.qr_url}" alt="QR code">`;
await refresh();
connectSession(result.sid);
});
}
function connectSession(sid) {
activeSid = sid;
if (ws) ws.close();
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
ws = new WebSocket(`${protocol}://${window.location.host}/ws/instructor/${sid}`);
ws.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "state") currentState = message;
if (message.type === "lobby_update") roster = message.participants;
if (message.type === "live_histogram") liveHistogram = message;
if (message.type === "full_leaderboard") leaderboard = message.leaderboard;
if (message.type === "question_closed") liveHistogram = {histogram: message.histogram};
render();
});
render();
}
boot();

View File

@@ -1,2 +1,223 @@
const app = document.querySelector("#app");
app.textContent = "Loading quiz...";
const params = new URLSearchParams(window.location.search);
const sid = params.get("sid");
let ws = null;
let me = null;
let activeQuestion = null;
let submitted = null;
let countdownTimer = null;
function html(strings, ...values) {
return strings.map((part, index) => part + (values[index] ?? "")).join("");
}
function escapeText(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[char]);
}
function setView(markup) {
app.innerHTML = `<section class="shell">${markup}</section>`;
}
function askForLink() {
setView(html`<div class="panel center">
<h1>Ask your instructor for the link</h1>
<p>This quiz link is missing or no longer valid.</p>
</div>`);
}
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: "same-origin",
headers: {"Content-Type": "application/json", ...(options.headers || {})},
...options,
});
if (!response.ok) throw new Error(await response.text());
return response.json();
}
async function boot() {
if (!sid) {
askForLink();
return;
}
try {
await api(`/api/session/${sid}`);
} catch {
askForLink();
return;
}
try {
me = await api(`/api/session/${sid}/me`);
connect();
} catch {
renderJoin();
}
}
function renderJoin(error = "") {
setView(html`<div class="panel narrow">
<h1>Join Quiz</h1>
<form id="join-form" class="stack">
<label>Student ID <input name="student_id" autocomplete="username" required></label>
<label>Name <input name="name" autocomplete="name" required></label>
${error ? `<p class="error">${escapeText(error)}</p>` : ""}
<button class="primary" type="submit">Join</button>
</form>
</div>`);
document.querySelector("#join-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
await api(`/api/session/${sid}/join`, {
method: "POST",
body: JSON.stringify({
student_id: form.get("student_id"),
name: form.get("name"),
}),
});
me = await api(`/api/session/${sid}/me`);
connect();
} catch {
renderJoin("Could not join this session.");
}
});
}
function connect() {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
ws = new WebSocket(`${protocol}://${window.location.host}/ws/student/${sid}`);
ws.addEventListener("message", (event) => handleMessage(JSON.parse(event.data)));
ws.addEventListener("close", () => {
clearInterval(countdownTimer);
setView(html`<div class="panel center"><h1>Connection closed</h1><p>Refresh the page to reconnect.</p></div>`);
});
}
function handleMessage(message) {
if (message.type === "state") renderState(message);
if (message.type === "question_open") renderQuestion(message);
if (message.type === "submit_ack") renderSubmitted(message);
if (message.type === "question_closed") renderReveal(message);
if (message.type === "between_questions") renderBetween(message);
if (message.type === "session_ended") renderFinished(message);
if (message.type === "error") renderError(message.message);
}
function renderState(message) {
activeQuestion = null;
submitted = null;
clearInterval(countdownTimer);
if (message.state === "lobby") {
setView(html`<div class="panel center">
<h1>${escapeText(message.title)}</h1>
<p class="status">You are in. Waiting for instructor to start.</p>
<p>${escapeText(me?.name || "")}</p>
<div class="spinner"></div>
</div>`);
}
}
function renderQuestion(message) {
activeQuestion = message;
submitted = null;
const buttons = Object.entries(message.options).map(([key, value]) => (
`<button class="answer" data-answer="${key}"><strong>${key}</strong><span>${escapeText(value)}</span></button>`
)).join("");
setView(html`<article class="panel quiz-panel">
<div class="topline"><span>Question ${message.question_idx + 1}</span><span id="timer"></span></div>
<div class="bar"><span id="bar-fill"></span></div>
<h1>${escapeText(message.text)}</h1>
<div class="answers">${buttons}</div>
</article>`);
document.querySelectorAll("[data-answer]").forEach((button) => {
button.addEventListener("click", () => submitAnswer(button.dataset.answer));
});
startCountdown(message);
}
function startCountdown(message) {
clearInterval(countdownTimer);
const endAt = Date.now() + message.remaining_ms;
const total = message.time_limit * 1000;
const tick = () => {
const remaining = Math.max(0, endAt - Date.now());
const timer = document.querySelector("#timer");
const fill = document.querySelector("#bar-fill");
if (timer) timer.textContent = `${Math.ceil(remaining / 1000)}s`;
if (fill) fill.style.width = `${Math.max(0, Math.min(100, remaining / total * 100))}%`;
};
tick();
countdownTimer = setInterval(tick, 250);
}
function submitAnswer(answer) {
if (!ws || !activeQuestion || submitted) return;
ws.send(JSON.stringify({type: "submit", question_idx: activeQuestion.question_idx, answer}));
document.querySelectorAll("[data-answer]").forEach((button) => button.disabled = true);
}
function renderSubmitted(message) {
submitted = message;
const seconds = (message.elapsed_ms / 1000).toFixed(1);
setView(html`<div class="panel center">
<h1>Submitted</h1>
<p class="score">Submitted in ${seconds}s, +${message.score} pts.</p>
<p>Wait for the reveal.</p>
</div>`);
}
function renderReveal(message) {
clearInterval(countdownTimer);
const rows = Object.entries(message.histogram).map(([key, value]) => (
`<div class="hist-row"><span>${key}</span><meter min="0" max="50" value="${value}"></meter><b>${value}</b></div>`
)).join("");
const board = renderBoard(message.top5);
setView(html`<article class="panel">
<p class="status">Correct answer: ${escapeText(message.correct)}</p>
<h1>Reveal</h1>
<p>Your answer: ${escapeText(message.your_answer || "missed")}. Score: ${message.your_score || 0}.</p>
${message.explanation ? `<p>${escapeText(message.explanation)}</p>` : ""}
<div class="histogram">${rows}</div>
${board}
<p>Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.</p>
</article>`);
}
function renderBetween(message) {
setView(html`<div class="panel center">
<h1>Next question coming up</h1>
<p>Your rank: ${message.your_rank ?? "pending"}</p>
<p>Total: ${message.your_total ?? 0}</p>
${renderBoard(message.top5)}
</div>`);
}
function renderFinished(message) {
setView(html`<div class="panel center celebration">
<h1>Quiz finished</h1>
<p>Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.</p>
<p>Answered ${message.questions_answered ?? 0}, correct ${message.questions_correct ?? 0}.</p>
${renderBoard(message.final_top5)}
</div>`);
}
function renderBoard(rows = []) {
if (!rows.length) return "<p>No scores yet.</p>";
return `<ol class="leaderboard">${rows.map((row) => (
`<li><span>${row.rank}. ${escapeText(row.name)}</span><strong>${row.score}</strong></li>`
)).join("")}</ol>`;
}
function renderError(message) {
setView(html`<div class="panel center"><h1>Quiz message</h1><p>${escapeText(message)}</p></div>`);
}
boot();

View File

@@ -1,6 +1,8 @@
:root {
color-scheme: light dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f6f7f9;
color: #18212f;
}
body {
@@ -15,3 +17,283 @@ body {
margin: 0 auto;
padding: 24px;
}
.panel {
background: #ffffff;
border: 1px solid #d9dee7;
border-radius: 8px;
padding: 24px;
box-shadow: 0 10px 30px rgba(19, 33, 54, 0.08);
}
.narrow {
max-width: 440px;
margin: 8vh auto;
}
.center {
text-align: center;
}
.stack {
display: grid;
gap: 16px;
}
h1, h2 {
margin-top: 0;
}
label {
display: grid;
gap: 6px;
font-weight: 700;
}
input, textarea, select, button, .button {
font: inherit;
}
input, textarea, select {
border: 1px solid #b8c0cc;
border-radius: 8px;
padding: 10px 12px;
background: #fff;
color: #18212f;
}
button, .button {
border: 1px solid #9aa7b8;
border-radius: 8px;
padding: 10px 14px;
background: #eef1f5;
color: #18212f;
cursor: pointer;
text-decoration: none;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.primary {
background: #0d6b57;
border-color: #0d6b57;
color: #fff;
}
.secondary {
background: #254f7a;
border-color: #254f7a;
color: #fff;
}
.danger {
background: #a43831;
border-color: #a43831;
color: #fff;
}
.error {
color: #a43831;
font-weight: 700;
}
.status, .score {
font-size: 1.2rem;
font-weight: 800;
}
.spinner {
width: 36px;
height: 36px;
margin: 20px auto 0;
border: 4px solid #cbd3df;
border-top-color: #0d6b57;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.quiz-panel h1 {
font-size: clamp(1.5rem, 4vw, 2.5rem);
}
.topline {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
font-weight: 800;
}
.bar {
height: 12px;
background: #e0e6ef;
border-radius: 999px;
overflow: hidden;
margin: 12px 0 24px;
}
.bar span {
display: block;
height: 100%;
width: 100%;
background: #0d6b57;
transition: width 0.2s linear;
}
.answers {
display: grid;
gap: 12px;
}
.answer {
display: grid;
grid-template-columns: 42px 1fr;
gap: 12px;
min-height: 68px;
align-items: center;
text-align: left;
background: #fff;
}
.answer strong {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: #254f7a;
color: #fff;
}
.histogram {
display: grid;
gap: 10px;
margin: 16px 0;
}
.hist-row {
display: grid;
grid-template-columns: 72px 1fr 48px;
gap: 10px;
align-items: center;
}
meter {
width: 100%;
}
.leaderboard {
display: grid;
gap: 8px;
padding-left: 0;
list-style: none;
}
.leaderboard li {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 10px 0;
border-bottom: 1px solid #e2e7ee;
}
.celebration {
outline: 6px solid #f2c94c;
}
.admin-layout {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.sidebar {
padding: 20px;
background: #223044;
color: #fff;
}
.sidebar .list {
display: grid;
gap: 8px;
margin-bottom: 24px;
}
.sidebar button {
width: 100%;
text-align: left;
}
.workspace {
padding: 24px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 16px 0;
}
.roster {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.roster span {
border: 1px solid #ccd4df;
border-radius: 999px;
padding: 6px 10px;
}
.qr {
width: min(280px, 100%);
height: auto;
background: #fff;
padding: 12px;
}
@media (max-width: 720px) {
.shell, .workspace {
padding: 14px;
}
.admin-layout {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
}
.panel {
padding: 18px;
}
}
@media (prefers-color-scheme: dark) {
:root, body {
background: #10141b;
color: #edf1f7;
}
.panel, input, textarea, select, .answer {
background: #171d27;
color: #edf1f7;
border-color: #344052;
}
button, .button {
background: #263246;
color: #edf1f7;
border-color: #4a586d;
}
}

View File

@@ -1 +1,94 @@
"""Test fixtures."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import create_app
@pytest.fixture
def sample_pool():
return {
"title": "Sample Quiz",
"score_fn": "linear_decay",
"time_limit_default": 2,
"questions": [
{
"id": "q1",
"text": "First question?",
"options": {"A": "Alpha", "B": "Beta", "C": "Gamma", "D": "Delta"},
"correct": "B",
"time_limit": 2,
"explanation": "B is correct.",
},
{
"id": "q2",
"text": "Second question?",
"options": {"A": "One", "B": "Two", "C": "Three", "D": "Four"},
"correct": "C",
"time_limit": 2,
},
{
"id": "q3",
"text": "Third question?",
"options": {"A": "Red", "B": "Blue", "C": "Green", "D": "Gold"},
"correct": "A",
"time_limit": 2,
},
{
"id": "q4",
"text": "Fourth question?",
"options": {"A": "North", "B": "South", "C": "East", "D": "West"},
"correct": "D",
"time_limit": 2,
},
{
"id": "q5",
"text": "Fifth question?",
"options": {"A": "Fetch", "B": "Decode", "C": "Execute", "D": "Write"},
"correct": "A",
"time_limit": 1,
},
],
}
@pytest.fixture
def client(tmp_path):
settings = Settings(
db_path=str(tmp_path / "quiz.db"),
secret_key="test-secret",
admin_password="admin-pass",
public_url="http://testserver",
)
app = create_app(settings)
with TestClient(app) as test_client:
yield test_client
def admin_login(client: TestClient) -> None:
response = client.post("/admin/login", json={"password": "admin-pass"})
assert response.status_code == 200
def create_quiz(client: TestClient, pool: dict) -> int:
admin_login(client)
response = client.post("/admin/api/quizzes", json={"pool_json": pool})
assert response.status_code == 200, response.text
return response.json()["quiz_id"]
def create_session(client: TestClient, pool: dict) -> str:
quiz_id = create_quiz(client, pool)
response = client.post("/admin/api/sessions", json={"quiz_id": quiz_id})
assert response.status_code == 200, response.text
return response.json()["sid"]
def join_student(client: TestClient, sid: str, student_id: str = "s1", name: str = "Student One") -> dict:
response = client.post(f"/api/session/{sid}/join", json={"student_id": student_id, "name": name})
assert response.status_code == 200, response.text
return response.json()

2
tests/stress/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
runs/

34
tests/stress/README.md Normal file
View File

@@ -0,0 +1,34 @@
# Quiz portal stress harness
Adversarial frontend + API stress tests for the quiz portal. Built 2026-05-02.
## Files
- `lib.mjs` — shared helpers: server boot, cookie jar, `Student` and `Admin` WS wrappers, the fixed `STRESS_POOL`.
- `api_stress.mjs` — pure WS adversarial scenarios (no browser): happy path with 20 concurrent students, late join, mid-question disconnect, **sleep/wake to next question** (the phone-screen-sleep scenario), cookie tampering, cross-session cookie reuse, duplicate student_id, bad submits (out-of-order, wrong idx, resubmit), close-boundary race, malformed-JSON fuzz, flaky reconnect.
- `ui_stress.mjs` — Playwright/Chromium scenarios that exercise the real SPA: happy UI flow, sleep/wake by closing+reopening browser context with persisted cookie, cookie-tamper via `document.cookie`, two browsers with same student_id.
- `run_loop.sh` — bash wrapper that runs `api_stress.mjs` every cycle and `ui_stress.mjs` every `UI_EVERY` cycles (default 5), with a fresh random seed each time. Logs JSON summary lines to `runs/summary.jsonl` and full output to `runs/run-<timestamp>.jsonl`.
## Quick start
```bash
# One-shot
node api_stress.mjs # uses Date.now() seed
node api_stress.mjs 12345 8210 # explicit seed + port
node ui_stress.mjs # browser-based; HEADLESS=0 to watch
# Long-running loop in tmux
tmux new -d -s quiz_stress 'cd /home/ameer/RD/Projects/Apps/quiz/tests/stress && bash run_loop.sh'
tmux attach -t quiz_stress # to watch
tmux send -t quiz_stress C-c # to stop
```
Each cycle boots a fresh uvicorn on its own port and clean DB, runs scenarios, then tears down. Failures are recorded in the `failures` array of the per-cycle summary line.
## Known findings (tracked outside this dir)
- **Codex bug:** `app/room.py` `student_ws` (line ~87) and `instructor_ws` call `await websocket.receive_json()` whose JSON parsing can raise `JSONDecodeError`, but the surrounding `try/except` only catches `WebSocketDisconnect`. Result: a single malformed message kills that client's WS handler. The fuzz scenario in `api_stress.mjs` flags this consistently. Fix: wrap the receive in `try/except (JSONDecodeError, RuntimeError):` and either close cleanly or send `{"type":"error","code":"bad_message"}` and continue.
## Adding scenarios
Write an `async function name(server) { ... }` in `api_stress.mjs` (or `(server, browser)` for UI), add it to the `SCENARIOS` map / array, and re-run. Use `expect(cond, scenario, msg, extra)` for assertions and `note(scenario, msg)` for warnings that shouldn't fail the suite. **Critical pattern:** pre-register `waitFor` waiters BEFORE the action that triggers the message — `Student.waitFor(type)` only resolves on NEW messages, not cached ones, to avoid stale-state false passes.

461
tests/stress/api_stress.mjs Normal file
View File

@@ -0,0 +1,461 @@
// API-level adversarial stress tests for the quiz portal.
// Each scenario boots a fresh server on its own port, runs assertions,
// and logs JSON lines to stdout. Designed to be run repeatedly with
// different seeds; see run_loop.sh for the wrapper.
import { bootServer, setupSession, Student, Admin, STRESS_POOL, sleep, logLine, rand, pickRandom } from "./lib.mjs";
import WebSocket from "ws";
const SEED = parseInt(process.argv[2] || Date.now(), 10);
const PORT = parseInt(process.argv[3] || (8200 + (SEED % 100)), 10);
let mulberry32 = (s) => () => {
s |= 0; s = s + 0x6D2B79F5 | 0;
let t = Math.imul(s ^ s >>> 15, 1 | s);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
const rng = mulberry32(SEED);
let pass = 0, fail = 0, warn = 0;
const failures = [];
function expect(cond, scenario, msg, extra = {}) {
if (cond) { pass++; logLine(scenario, "pass", msg, extra); }
else { fail++; failures.push({ scenario, msg, extra }); logLine(scenario, "fail", msg, extra); }
}
function note(scenario, msg, extra = {}) { warn++; logLine(scenario, "note", msg, extra); }
async function runScenario(name, fn) {
logLine(name, "start", `seed=${SEED}`);
let server;
try {
server = await bootServer({ port: PORT });
await fn(server);
logLine(name, "ok", "scenario completed");
} catch (err) {
fail++;
failures.push({ scenario: name, msg: "uncaught", extra: { err: err.message, stack: err.stack?.slice(0, 600) } });
logLine(name, "fail", "uncaught exception", { err: err.message });
} finally {
if (server) await server.stop();
}
}
// ---------- Scenarios ----------
async function happyPath(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const N = 20;
const students = await Promise.all(Array.from({ length: N }, async (_, i) => {
const s = new Student(server.url, sid, `S${i.toString().padStart(3, "0")}`, `Student${i}`);
await s.join();
await s.connect();
return s;
}));
const admin = new Admin(server.url, sid, jar);
await admin.connect();
await admin.waitFor("lobby_update");
for (let q = 0; q < STRESS_POOL.questions.length; q++) {
// Pre-register waiters so we don't lose the broadcast in the race window
const studentOpenWaits = students.map(s => s.waitFor("question_open"));
const adminOpenWait = admin.waitFor("question_open");
admin.open(q, 5);
await adminOpenWait;
await Promise.all(studentOpenWaits);
// Each student picks a random answer (mostly correct)
await Promise.all(students.map(async (s, i) => {
try {
await sleep(rand(50, 800));
const correct = STRESS_POOL.questions[q].correct;
const ans = rng() < 0.7 ? correct : pickRandom(["A","B","C","D"]);
const ackWait = s.waitFor("submit_ack", { timeoutMs: 3000 });
s.submit(q, ans);
const ack = await ackWait;
expect(ack.question_idx === q, "happy", `student${i} q${q} ack idx==q`, { ack_idx: ack.question_idx, expected: q });
} catch (e) {
note("happy", `student${i} q${q}: ${e.message}`);
}
}));
const studentClosedWaits = students.map(s => s.waitFor("question_closed", { timeoutMs: 3000 }).catch(() => null));
const adminClosedWait = admin.waitFor("question_closed", { timeoutMs: 3000 });
admin.close();
await adminClosedWait;
await Promise.all(studentClosedWaits);
if (q < STRESS_POOL.questions.length - 1) {
admin.next();
await sleep(150);
}
}
const sessionEndedWait = admin.waitFor("session_ended", { timeoutMs: 3000 });
admin.end();
await sessionEndedWait;
expect(true, "happy", "session ended cleanly");
students.forEach(s => s.disconnect());
admin.disconnect();
}
async function lateJoiners(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar);
await admin.connect();
// 5 students join in lobby
const early = await Promise.all([0,1,2,3,4].map(async i => {
const s = new Student(server.url, sid, `E${i}`, `Early${i}`);
await s.join(); await s.connect(); return s;
}));
const adminOpenWait = admin.waitFor("question_open");
admin.open(0, 8);
await adminOpenWait;
await sleep(2000);
// 3 late joiners
for (let i = 0; i < 3; i++) {
const s = new Student(server.url, sid, `L${i}`, `Late${i}`);
await s.join();
// Pre-register waiters BEFORE connect so we catch the snapshot on connect
const stateWait = s.waitFor("state");
const qopenWait = s.waitFor("question_open", { timeoutMs: 2000 });
await s.connect();
const m = await qopenWait.catch(() => null);
if (!m) { fail++; failures.push({ scenario: "late_join", msg: `late${i} got no question_open on connect`, extra: {} }); logLine("late_join", "fail", `late${i} got no question_open on connect`); continue; }
expect(m.question_idx === 0, "late_join", `late${i} sees correct idx`);
expect(m.remaining_ms < 8000, "late_join", `late${i} remaining_ms reduced`, { remaining_ms: m.remaining_ms });
expect(m.remaining_ms > 0, "late_join", `late${i} remaining_ms > 0`, { remaining_ms: m.remaining_ms });
const ackWait = s.waitFor("submit_ack", { timeoutMs: 3000 });
s.submit(0, STRESS_POOL.questions[0].correct);
const ack = await ackWait.catch(() => null);
expect(ack && ack.score > 0, "late_join", `late${i} got positive score from late submit`, { score: ack?.score });
s.disconnect();
}
const adminClosedWait = admin.waitFor("question_closed");
admin.close();
await adminClosedWait;
early.forEach(s => s.disconnect());
admin.disconnect();
}
async function disconnectMidQuestion(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar);
await admin.connect();
const s = new Student(server.url, sid, "D1", "Drop");
await s.join();
const stateWaitInitial = s.waitFor("state");
await s.connect();
await stateWaitInitial;
const sQopenWait = s.waitFor("question_open");
const aOpenWait = admin.waitFor("question_open");
admin.open(0, 10);
await aOpenWait;
await sQopenWait;
s.disconnect();
await sleep(500);
// While dropped, instructor closes
const closedWait = admin.waitFor("question_closed");
admin.close();
await closedWait;
// Reconnect — should get state=question_closed (or current state)
const reconnectStateWait = s.waitFor("state", { timeoutMs: 2000 });
await s.reconnect();
const state = await reconnectStateWait;
expect(["question_closed", "between_questions", "lobby"].includes(state.state), "disconnect_midq", `state on reconnect = ${state.state}`);
s.disconnect();
admin.disconnect();
}
// THE KEY scenario from the user: phone screen sleeps mid-quiz, instructor
// advances to a new question, phone wakes — the student MUST see the
// LATEST question, not a stale screen.
async function sleepWakeNextQuestion(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar);
await admin.connect();
const s = new Student(server.url, sid, "SW1", "SleepWake");
await s.join();
const initState = s.waitFor("state");
await s.connect();
await initState;
// Open Q0
let sWait = s.waitFor("question_open");
let aWait = admin.waitFor("question_open");
admin.open(0, 5);
await aWait; await sWait;
await sleep(500);
const ackWait = s.waitFor("submit_ack");
s.submit(0, "B");
await ackWait;
// Phone "sleeps" — drop WS hard
s.disconnect();
await sleep(800);
// Instructor closes, advances, opens Q2 (skip Q1 to make wake state non-trivial)
let closedW = admin.waitFor("question_closed");
admin.close();
await closedW;
admin.next();
await sleep(150);
aWait = admin.waitFor("question_open");
admin.open(2, 8);
await aWait;
await sleep(300);
// Phone wakes — reconnect; pre-register both expected events
const stateOnWake = s.waitFor("state", { timeoutMs: 2000 });
const qopenOnWake = s.waitFor("question_open", { timeoutMs: 2000 });
await s.reconnect();
const state = await stateOnWake;
expect(state.state === "question_open", "sleep_wake", `state on wake = ${state.state}`, { state });
expect(state.current_question_idx === 2, "sleep_wake", `current_question_idx on wake = ${state.current_question_idx}`);
const qopen = await qopenOnWake.catch(() => null);
expect(qopen && qopen.question_idx === 2, "sleep_wake", "reconnect emits question_open for current idx", { qopen_idx: qopen?.question_idx });
expect(qopen && qopen.text === STRESS_POOL.questions[2].text, "sleep_wake", "question text matches latest Q");
if (qopen) {
const ack2W = s.waitFor("submit_ack", { timeoutMs: 3000 });
s.submit(2, "B");
const ack = await ack2W.catch(() => null);
expect(ack && ack.question_idx === 2, "sleep_wake", "submit on Q2 after wake succeeded", { ack_idx: ack?.question_idx });
}
s.disconnect();
admin.disconnect();
}
// Cookie tampering: try to flip student_id by mangling the signed cookie
async function cookieTampering(server) {
const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const s = new Student(server.url, sid, "ORIG", "Original");
await s.join();
// Tamper: append a junk byte
const original = s.jar.get("qz_student");
s.jar.set("qz_student", original + "X");
// Try to connect WS
let connected = false, closeCode = null;
try {
await new Promise((res, rej) => {
const wsUrl = server.url.replace(/^http/, "ws") + `/ws/student/${sid}`;
const w = new WebSocket(wsUrl, { headers: { Cookie: s.jar.header() } });
w.on("open", () => { connected = true; w.close(); res(); });
w.on("close", (c) => { closeCode = c; res(); });
w.on("error", () => res());
setTimeout(res, 1500);
});
} catch {}
expect(!connected || closeCode === 4001 || closeCode === 1006 || closeCode === 1008, "cookie_tamper", "tampered cookie rejected on WS", { connected, closeCode });
// Reset cookie and retry
s.jar.set("qz_student", original);
await s.connect();
await s.waitFor("state");
expect(true, "cookie_tamper", "valid cookie still works after tamper attempt");
s.disconnect();
}
// Cross-session cookie: cookie from session A should not work on session B.
async function crossSessionCookie(server) {
const { sid: sidA, jar: jarA } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const { sid: sidB } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const s = new Student(server.url, sidA, "X1", "CrossUser");
await s.join();
// Try to use sidA's cookie to access sidB
const wsUrl = server.url.replace(/^http/, "ws") + `/ws/student/${sidB}`;
let opened = false;
await new Promise(res => {
const w = new WebSocket(wsUrl, { headers: { Cookie: s.jar.header() } });
w.on("open", () => { opened = true; w.close(); res(); });
w.on("close", () => res());
w.on("error", () => res());
setTimeout(res, 1500);
});
expect(!opened, "cross_session", "cookie from sidA rejected when used against sidB", { opened });
}
// Duplicate student_id: two browsers join with same student_id (different cookies)
async function duplicateStudentId(server) {
const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const s1 = new Student(server.url, sid, "DUP", "FirstSession");
const s2 = new Student(server.url, sid, "DUP", "SecondSession");
await s1.join();
const s1State = s1.waitFor("state");
await s1.connect();
await s1State;
await s2.join();
const s2State = s2.waitFor("state");
await s2.connect();
await s2State;
expect(s1.ws.readyState === WebSocket.OPEN, "dup_id", "first DUP session open");
expect(s2.ws.readyState === WebSocket.OPEN, "dup_id", "second DUP session open");
s1.disconnect(); s2.disconnect();
}
// Submit out-of-order / wrong question_idx
async function badSubmits(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar); await admin.connect();
const s = new Student(server.url, sid, "BAD", "Bad");
await s.join();
const initState = s.waitFor("state");
await s.connect();
await initState;
// Submit before any question opened
s.submit(0, "A");
await sleep(300);
expect(!s.lastMsgByType.submit_ack, "bad_submit", "submit before open ignored / no ack", { last: Object.keys(s.lastMsgByType) });
let sWait = s.waitFor("question_open");
let aWait = admin.waitFor("question_open");
admin.open(0, 5);
await aWait; await sWait;
// Wrong idx submit (give it 600 ms to arrive if it does)
const ackBefore = s.lastMsgByType.submit_ack;
s.submit(99, "A");
await sleep(600);
const ackAfter = s.lastMsgByType.submit_ack;
expect(ackAfter === ackBefore || (ackAfter && ackAfter.question_idx !== 99), "bad_submit", "wrong-idx submit not acked", { ackAfter });
// Valid submit
const okWait = s.waitFor("submit_ack", { timeoutMs: 2000 });
s.submit(0, "B");
const ok = await okWait;
expect(ok.question_idx === 0, "bad_submit", "valid submit acked");
// Resubmit (already submitted) — should NOT change stored answer
s.submit(0, "A");
await sleep(400);
const closedWait = s.waitFor("question_closed");
admin.close();
const closed = await closedWait;
expect(closed.your_answer === "B", "bad_submit", "your_answer remained 'B' after resubmit attempt", { your_answer: closed.your_answer });
s.disconnect(); admin.disconnect();
}
// Race: many students submit at the moment of close (within ms)
async function closeBoundaryRace(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar); await admin.connect();
const N = 30;
const students = await Promise.all(Array.from({ length: N }, async (_, i) => {
const s = new Student(server.url, sid, `R${i}`, `Race${i}`);
await s.join(); await s.connect(); return s;
}));
// Pre-register everyone's question_open + question_closed waits
const sOpens = students.map(s => s.waitFor("question_open", { timeoutMs: 3000 }).catch(() => null));
const aOpen = admin.waitFor("question_open", { timeoutMs: 3000 });
// Pre-register the closed wait too — auto-close fires even without manual close()
const aClosed = admin.waitFor("question_closed", { timeoutMs: 6000 });
admin.open(0, 1); // 1-second window — auto-close should fire ~1s later
await aOpen;
await Promise.all(sOpens);
// Have all students fire submit at random times spanning the window edge
const fires = students.map(async (s, i) => {
const ackW = s.waitFor("submit_ack", { timeoutMs: 2000 });
await sleep(rand(800, 1200)); // some fire after auto-close
s.submit(0, "B");
return ackW.catch(() => null);
});
const acks = await Promise.all(fires);
const acked = acks.filter(Boolean).length;
const closed = await aClosed.catch(() => null);
logLine("close_race", "info", `race results`, { acked, total: N, hist: closed?.histogram });
expect(closed !== null, "close_race", "question_closed broadcast received (auto-close or manual)");
expect(acked >= 1 && acked <= N, "close_race", "no crash, some submits succeeded");
students.forEach(s => s.disconnect()); admin.disconnect();
}
// Fuzz: malformed messages from a student WS
async function fuzzMessages(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar); await admin.connect();
// First: a sentinel student that does NOT receive fuzz, to verify global server health
const sentinel = new Student(server.url, sid, "SENT", "Sentinel");
await sentinel.join();
const sentinelStateW = sentinel.waitFor("state");
await sentinel.connect();
await sentinelStateW;
// Second: a fuzz student that gets garbage shoved at it
const s = new Student(server.url, sid, "FZ", "Fuzz");
await s.join();
const fzStateW = s.waitFor("state");
await s.connect();
await fzStateW;
const garbages = [
"not json",
"{}",
JSON.stringify({ type: "open_question" }), // student trying to act as instructor
JSON.stringify({ type: "submit", question_idx: -1, answer: "Z" }),
JSON.stringify({ type: "submit", question_idx: 0, answer: { nested: "obj" } }),
JSON.stringify({ type: "💀" }),
"x".repeat(50_000),
];
for (const g of garbages) { try { s.ws.send(g); } catch {} ; await sleep(50); }
await sleep(500);
// Server should still serve OTHER clients regardless of what happened to fuzz student.
const sentOpenW = sentinel.waitFor("question_open", { timeoutMs: 2000 });
const adminOpenW = admin.waitFor("question_open", { timeoutMs: 2000 });
admin.open(0, 3);
await adminOpenW;
const sm = await sentOpenW.catch(() => null);
expect(sm && sm.question_idx === 0, "fuzz", "OTHER clients still served after fuzz on one student", { got: !!sm });
// Did the fuzz student survive? (informational, not asserted as pass/fail)
const survived = !s.closed && s.ws.readyState === WebSocket.OPEN;
logLine("fuzz", "info", `fuzz student survival`, { survived, ws_state: s.ws.readyState });
if (!survived) note("fuzz", "fuzz student WS was killed by malformed input — server lacks JSON-decode try/except in WS loop (codex room.py student_ws line ~87)");
const adminClosedW = admin.waitFor("question_closed", { timeoutMs: 5000 });
admin.close();
await adminClosedW.catch(() => null);
s.disconnect(); sentinel.disconnect(); admin.disconnect();
}
// Repeated rapid connect/disconnect (simulating flaky network)
async function flakyReconnect(server) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const admin = new Admin(server.url, sid, jar); await admin.connect();
const s = new Student(server.url, sid, "FK", "Flaky");
await s.join();
for (let i = 0; i < 10; i++) {
const stateW = s.waitFor("state", { timeoutMs: 2000 });
await s.connect();
await stateW.catch(() => {});
s.disconnect();
await sleep(rand(50, 200));
}
// Final reconnect
const finalStateW = s.waitFor("state", { timeoutMs: 2000 });
await s.connect();
await finalStateW;
const sQopenW = s.waitFor("question_open");
const aQopenW = admin.waitFor("question_open");
admin.open(0, 5);
await aQopenW; await sQopenW;
const ackW = s.waitFor("submit_ack", { timeoutMs: 3000 });
s.submit(0, "B");
const ack = await ackW;
expect(ack !== null, "flaky_reconnect", "post-flaky student can still submit");
const closedW = admin.waitFor("question_closed");
admin.close();
await closedW;
s.disconnect(); admin.disconnect();
}
const SCENARIOS = {
happy: happyPath,
late_join: lateJoiners,
disconnect_midq: disconnectMidQuestion,
sleep_wake: sleepWakeNextQuestion,
cookie_tamper: cookieTampering,
cross_session: crossSessionCookie,
dup_id: duplicateStudentId,
bad_submit: badSubmits,
close_race: closeBoundaryRace,
fuzz: fuzzMessages,
flaky_reconnect: flakyReconnect,
};
// Pick scenario subset based on env or run all in random order
const wanted = process.env.SCENARIOS
? process.env.SCENARIOS.split(",")
: Object.keys(SCENARIOS).sort(() => rng() - 0.5);
logLine("runner", "info", `starting api stress`, { seed: SEED, port: PORT, scenarios: wanted });
for (const name of wanted) {
const fn = SCENARIOS[name];
if (!fn) { logLine("runner", "warn", `unknown scenario ${name}`); continue; }
await runScenario(name, fn);
}
logLine("runner", "summary", `done`, { pass, fail, warn, failures });
process.exit(fail > 0 ? 1 : 0);

256
tests/stress/lib.mjs Normal file
View File

@@ -0,0 +1,256 @@
// Shared helpers for quiz portal stress tests.
// Boots a fresh uvicorn server, logs in as admin, creates quiz + session.
// Provides a Student class that wraps an authenticated WS + cookie state.
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import WebSocket from "ws";
const QUIZ_ROOT = "/home/ameer/RD/Projects/Apps/quiz";
const PORT_BASE = 8200;
export function nowMs() { return Date.now(); }
export function logLine(scenario, level, msg, extra = {}) {
const rec = { ts: new Date().toISOString(), scenario, level, msg, ...extra };
process.stdout.write(JSON.stringify(rec) + "\n");
}
export function pickRandom(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
export function rand(min, max) { return Math.random() * (max - min) + min; }
// Boot a fresh server on its own port + DB. Returns { url, stop }.
export async function bootServer({ port, secret = "stress-secret-12345678", adminPw = "stresspw" } = {}) {
const tmp = mkdtempSync(join(tmpdir(), "quiz-stress-"));
const dbPath = join(tmp, "stress.db");
const env = {
...process.env,
QUIZ_DB_PATH: dbPath,
QUIZ_SECRET_KEY: secret,
QUIZ_ADMIN_PASSWORD: adminPw,
QUIZ_HOST: "127.0.0.1",
QUIZ_PORT: String(port),
QUIZ_PUBLIC_URL: `http://127.0.0.1:${port}`,
};
const proc = spawn(
`${QUIZ_ROOT}/.venv/bin/uvicorn`,
["app.main:app", "--host", "127.0.0.1", "--port", String(port), "--log-level", "warning"],
{ cwd: QUIZ_ROOT, env, stdio: ["ignore", "pipe", "pipe"] },
);
// Pipe server stderr to our stderr so panics are visible
proc.stderr.on("data", chunk => process.stderr.write(`[server:${port}] ${chunk}`));
const url = `http://127.0.0.1:${port}`;
// Wait for /healthz
const deadline = Date.now() + 20_000;
while (Date.now() < deadline) {
try {
const r = await fetch(`${url}/healthz`);
if (r.ok) {
return {
url,
adminPw,
stop: () => new Promise(res => {
proc.once("exit", () => { rmSync(tmp, { recursive: true, force: true }); res(); });
proc.kill("SIGTERM");
// Hard-kill fallback
setTimeout(() => proc.kill("SIGKILL"), 2000);
}),
};
}
} catch {}
await sleep(150);
}
proc.kill("SIGKILL");
throw new Error(`server on ${port} did not come up`);
}
// Cookie jar helper - parses Set-Cookie headers from fetch response.
export class CookieJar {
constructor() { this.jar = new Map(); }
ingest(response) {
const raw = response.headers.getSetCookie?.() || (response.headers.get("set-cookie") ? [response.headers.get("set-cookie")] : []);
for (const line of raw) {
const [pair] = line.split(";");
const eq = pair.indexOf("=");
if (eq > 0) this.jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
}
header() {
return [...this.jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
}
get(name) { return this.jar.get(name); }
set(name, value) { this.jar.set(name, value); }
clear() { this.jar.clear(); }
}
export async function jsonReq(method, url, { jar, body, headers = {} } = {}) {
const opts = { method, headers: { ...headers } };
if (jar) opts.headers["Cookie"] = jar.header();
if (body !== undefined) {
opts.body = JSON.stringify(body);
opts.headers["Content-Type"] = "application/json";
}
const r = await fetch(url, opts);
if (jar) jar.ingest(r);
let data = null;
const txt = await r.text();
try { data = txt ? JSON.parse(txt) : null; } catch { data = txt; }
return { status: r.status, ok: r.ok, data, headers: r.headers };
}
// Build admin session: login + upload pool + create session. Returns { sid, jar }.
export async function setupSession(serverUrl, adminPw, pool) {
const jar = new CookieJar();
const login = await jsonReq("POST", `${serverUrl}/admin/login`, { jar, body: { password: adminPw } });
if (!login.ok) throw new Error(`admin login failed: ${login.status} ${JSON.stringify(login.data)}`);
const create = await jsonReq("POST", `${serverUrl}/admin/api/quizzes`, { jar, body: { pool_json: pool } });
if (!create.ok) throw new Error(`quiz create failed: ${create.status} ${JSON.stringify(create.data)}`);
const sess = await jsonReq("POST", `${serverUrl}/admin/api/sessions`, { jar, body: { quiz_id: create.data.quiz_id } });
if (!sess.ok) throw new Error(`session create failed: ${sess.status} ${JSON.stringify(sess.data)}`);
return { sid: sess.data.sid, jar };
}
// Student wrapper: join + connect WS + collect messages.
export class Student {
constructor(serverUrl, sid, studentId, name) {
this.serverUrl = serverUrl;
this.sid = sid;
this.studentId = studentId;
this.name = name;
this.jar = new CookieJar();
this.ws = null;
this.messages = [];
this.lastMsgByType = {};
this.events = new EventTarget();
this.closed = false;
}
async join() {
const r = await jsonReq("POST", `${this.serverUrl}/api/session/${this.sid}/join`, {
jar: this.jar,
body: { student_id: this.studentId, name: this.name },
});
if (!r.ok) throw new Error(`student join failed: ${r.status} ${JSON.stringify(r.data)}`);
return r;
}
connect() {
return new Promise((resolve, reject) => {
const wsUrl = this.serverUrl.replace(/^http/, "ws") + `/ws/student/${this.sid}`;
const headers = { Cookie: this.jar.header() };
this.ws = new WebSocket(wsUrl, { headers });
this.ws.on("open", () => resolve());
this.ws.on("message", buf => {
let msg;
try { msg = JSON.parse(buf.toString()); } catch { return; }
this.messages.push({ ts: nowMs(), msg });
this.lastMsgByType[msg.type] = msg;
this.events.dispatchEvent(new CustomEvent("msg", { detail: msg }));
});
this.ws.on("close", (code, reason) => {
this.closed = true;
this.events.dispatchEvent(new CustomEvent("close", { detail: { code, reason: reason.toString() } }));
});
this.ws.on("error", err => reject(err));
});
}
// Wait until a NEW message of the given type arrives (does not use cache).
// Use lastMsgByType[type] to inspect cached values without waiting.
waitFor(type, { timeoutMs = 5000, useCache = false } = {}) {
return new Promise((resolve, reject) => {
if (useCache && this.lastMsgByType[type]) return resolve(this.lastMsgByType[type]);
const handler = ev => {
if (ev.detail?.type === type) {
this.events.removeEventListener("msg", handler);
clearTimeout(timer);
resolve(ev.detail);
}
};
const timer = setTimeout(() => {
this.events.removeEventListener("msg", handler);
reject(new Error(`timed out waiting for WS type=${type} after ${timeoutMs}ms`));
}, timeoutMs);
this.events.addEventListener("msg", handler);
});
}
send(obj) {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(obj));
}
submit(qIdx, answer) {
this.send({ type: "submit", question_idx: qIdx, answer });
}
disconnect() {
if (this.ws && !this.closed) {
try { this.ws.terminate(); } catch {}
}
}
async reconnect() {
this.closed = false;
this.lastMsgByType = {};
await this.connect();
}
}
// Admin WS wrapper.
export class Admin {
constructor(serverUrl, sid, jar) {
this.serverUrl = serverUrl;
this.sid = sid;
this.jar = jar;
this.ws = null;
this.messages = [];
this.events = new EventTarget();
}
connect() {
return new Promise((resolve, reject) => {
const wsUrl = this.serverUrl.replace(/^http/, "ws") + `/ws/instructor/${this.sid}`;
this.ws = new WebSocket(wsUrl, { headers: { Cookie: this.jar.header() } });
this.ws.on("open", () => resolve());
this.ws.on("message", buf => {
let msg;
try { msg = JSON.parse(buf.toString()); } catch { return; }
this.messages.push({ ts: nowMs(), msg });
this.events.dispatchEvent(new CustomEvent("msg", { detail: msg }));
});
this.ws.on("error", err => reject(err));
});
}
open(qIdx, timeLimit = 60) { this.ws.send(JSON.stringify({ type: "open_question", question_idx: qIdx, time_limit: timeLimit })); }
close() { this.ws.send(JSON.stringify({ type: "close_question" })); }
next() { this.ws.send(JSON.stringify({ type: "next" })); }
end() { this.ws.send(JSON.stringify({ type: "end_session" })); }
waitFor(type, { timeoutMs = 5000 } = {}) {
return new Promise((resolve, reject) => {
const handler = ev => {
if (ev.detail?.type === type) {
this.events.removeEventListener("msg", handler);
clearTimeout(timer);
resolve(ev.detail);
}
};
const timer = setTimeout(() => {
this.events.removeEventListener("msg", handler);
reject(new Error(`admin timed out waiting for type=${type} after ${timeoutMs}ms`));
}, timeoutMs);
this.events.addEventListener("msg", handler);
});
}
disconnect() { try { this.ws?.terminate(); } catch {} }
}
// A small fixed pool used for stress runs.
export const STRESS_POOL = {
title: "Stress Pool",
score_fn: "linear_decay",
time_limit_default: 10,
questions: [
{ id: "s1", text: "2+2?", options: { A: "3", B: "4", C: "5", D: "6" }, correct: "B", explanation: "" },
{ id: "s2", text: "Capital of France?", options: { A: "Berlin", B: "Madrid", C: "Paris", D: "Rome" }, correct: "C", explanation: "" },
{ id: "s3", text: "Fastest sort?", options: { A: "Bubble", B: "Quick", C: "Insertion", D: "Selection" }, correct: "B", explanation: "" },
{ id: "s4", text: "HTTP code for not found?", options: { A: "200", B: "301", C: "404", D: "500" }, correct: "C", explanation: "" },
{ id: "s5", text: "Speed of light (m/s)?", options: { A: "3e8", B: "3e6", C: "1.5e8", D: "9.8" }, correct: "A", explanation: "" },
],
};
export { sleep };

81
tests/stress/package-lock.json generated Normal file
View File

@@ -0,0 +1,81 @@
{
"name": "quiz-stress",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "quiz-stress",
"version": "0.1.0",
"dependencies": {
"playwright": "^1.58.2",
"ws": "^8.18.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

14
tests/stress/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "quiz-stress",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"api": "node api_stress.mjs",
"ui": "node ui_stress.mjs"
},
"dependencies": {
"ws": "^8.18.0",
"playwright": "^1.58.2"
}
}

66
tests/stress/run_loop.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Loop runner for the stress harness.
# Runs api_stress.mjs each cycle with a fresh random seed, and runs ui_stress.mjs
# every $UI_EVERY cycles (default 5). Logs JSON lines to runs/<timestamp>.jsonl.
# Run this in tmux: tmux new -d -s quiz_stress 'bash run_loop.sh'
set -uo pipefail
cd "$(dirname "$0")"
mkdir -p runs
UI_EVERY=${UI_EVERY:-5}
SLEEP_BETWEEN=${SLEEP_BETWEEN:-3}
LOG="runs/run-$(date -u +%Y%m%dT%H%M%SZ).jsonl"
SUM="runs/summary.jsonl"
echo "{\"event\":\"loop_start\",\"ts\":\"$(date -u +%FT%TZ)\",\"log\":\"$LOG\",\"ui_every\":$UI_EVERY}" | tee -a "$SUM"
cycle=0
total_pass=0
total_fail=0
total_warn=0
trap 'echo "{\"event\":\"loop_stop\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycles\":'$cycle',\"total_pass\":'$total_pass',\"total_fail\":'$total_fail',\"total_warn\":'$total_warn'}" | tee -a "$SUM"; exit 0' INT TERM
while true; do
cycle=$((cycle + 1))
seed=$(( (RANDOM * 32768 + RANDOM) % 1000000 ))
port=$((8200 + (cycle % 50)))
printf '\n----- cycle %d (seed=%d port=%d) api -----\n' "$cycle" "$seed" "$port" | tee -a "$LOG"
out=$(timeout 120 node api_stress.mjs "$seed" "$port" 2>&1)
echo "$out" | tee -a "$LOG" >/dev/null
summary=$(echo "$out" | grep '"runner"' | grep '"summary"' | tail -1)
if [ -n "$summary" ]; then
p=$(echo "$summary" | sed -n 's/.*"pass":\([0-9]*\).*/\1/p')
f=$(echo "$summary" | sed -n 's/.*"fail":\([0-9]*\).*/\1/p')
w=$(echo "$summary" | sed -n 's/.*"warn":\([0-9]*\).*/\1/p')
total_pass=$((total_pass + ${p:-0}))
total_fail=$((total_fail + ${f:-0}))
total_warn=$((total_warn + ${w:-0}))
echo "{\"event\":\"cycle\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycle\":$cycle,\"kind\":\"api\",\"seed\":$seed,\"port\":$port,\"pass\":${p:-0},\"fail\":${f:-0},\"warn\":${w:-0},\"running_pass\":$total_pass,\"running_fail\":$total_fail,\"running_warn\":$total_warn}" | tee -a "$SUM"
else
echo "{\"event\":\"cycle\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycle\":$cycle,\"kind\":\"api\",\"seed\":$seed,\"port\":$port,\"status\":\"NO_SUMMARY\"}" | tee -a "$SUM"
fi
if [ $((cycle % UI_EVERY)) -eq 0 ]; then
printf '\n----- cycle %d (seed=%d port=%d) ui -----\n' "$cycle" "$seed" "$((port + 100))" | tee -a "$LOG"
out=$(timeout 180 node ui_stress.mjs "$seed" "$((port + 100))" 2>&1)
echo "$out" | tee -a "$LOG" >/dev/null
summary=$(echo "$out" | grep '"runner"' | grep '"summary"' | tail -1)
if [ -n "$summary" ]; then
p=$(echo "$summary" | sed -n 's/.*"pass":\([0-9]*\).*/\1/p')
f=$(echo "$summary" | sed -n 's/.*"fail":\([0-9]*\).*/\1/p')
w=$(echo "$summary" | sed -n 's/.*"warn":\([0-9]*\).*/\1/p')
total_pass=$((total_pass + ${p:-0}))
total_fail=$((total_fail + ${f:-0}))
total_warn=$((total_warn + ${w:-0}))
echo "{\"event\":\"cycle\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycle\":$cycle,\"kind\":\"ui\",\"seed\":$seed,\"port\":$((port + 100)),\"pass\":${p:-0},\"fail\":${f:-0},\"warn\":${w:-0},\"running_pass\":$total_pass,\"running_fail\":$total_fail,\"running_warn\":$total_warn}" | tee -a "$SUM"
else
echo "{\"event\":\"cycle\",\"ts\":\"$(date -u +%FT%TZ)\",\"cycle\":$cycle,\"kind\":\"ui\",\"seed\":$seed,\"port\":$((port + 100)),\"status\":\"NO_SUMMARY\"}" | tee -a "$SUM"
fi
fi
sleep "$SLEEP_BETWEEN"
done

181
tests/stress/ui_stress.mjs Normal file
View File

@@ -0,0 +1,181 @@
// UI-side stress: real Chromium browser contexts driving the SPA.
// Tests scenarios that only matter at the JS layer:
// - happy path through the student SPA UI
// - sleep/wake (browser context closed mid-quiz, instructor advances, browser reopens)
// - cookie tampering via document.cookie
// - simultaneous browsers with same student_id
// Boots its own server. Slower than api_stress but exercises real DOM rendering.
import { bootServer, setupSession, STRESS_POOL, sleep, logLine, jsonReq, CookieJar, Admin } from "./lib.mjs";
import { chromium } from "playwright";
const SEED = parseInt(process.argv[2] || Date.now(), 10);
const PORT = parseInt(process.argv[3] || (8300 + (SEED % 100)), 10);
const HEADLESS = process.env.HEADLESS !== "0";
let pass = 0, fail = 0, warn = 0;
const failures = [];
function expect(cond, scenario, msg, extra = {}) {
if (cond) { pass++; logLine(scenario, "pass", msg, extra); }
else { fail++; failures.push({ scenario, msg, extra }); logLine(scenario, "fail", msg, extra); }
}
function note(scenario, msg, extra = {}) { warn++; logLine(scenario, "note", msg, extra); }
async function joinAsStudent(page, baseUrl, sid, sid_id, name) {
await page.goto(`${baseUrl}/?sid=${sid}`);
await page.waitForSelector('input[name="student_id"], input[id*=student]', { timeout: 5000 });
// Codex SPA uses input[name=student_id]
const idInput = await page.$('input[name="student_id"]');
const nameInput = await page.$('input[name="name"]');
if (!idInput || !nameInput) throw new Error("join form fields not found");
await idInput.fill(sid_id);
await nameInput.fill(name);
await page.click('button:has-text("Join")');
await page.waitForSelector('text=Waiting for instructor', { timeout: 5000 });
}
async function adminOpenQuestion(server, jar, sid, qIdx, timeLimit = 10) {
// Open via admin instructor WS
const admin = new Admin(server.url, sid, jar);
await admin.connect();
const w = admin.waitFor("question_open", { timeoutMs: 5000 });
admin.open(qIdx, timeLimit);
await w;
return admin;
}
// Scenario 1: happy path through the SPA
async function uiHappy(server, browser) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const ctx = await browser.newContext();
const page = await ctx.newPage();
await joinAsStudent(page, server.url, sid, "U1", "UIStudent");
const admin = await adminOpenQuestion(server, jar, sid, 0, 10);
await page.waitForSelector('text=2+2?', { timeout: 5000 });
expect(true, "ui_happy", "Q1 text rendered in browser");
await page.click('button:has-text("B")');
await page.waitForSelector('text=Submitted in', { timeout: 5000 });
expect(true, "ui_happy", "submitted view shown");
const closedW = admin.waitFor("question_closed", { timeoutMs: 5000 });
admin.close();
await closedW;
await page.waitForSelector('text=Reveal', { timeout: 5000 });
expect(true, "ui_happy", "reveal view shown");
admin.disconnect();
await ctx.close();
}
// Scenario 2: sleep/wake via real browser context close-and-reopen, with persisted cookie.
async function uiSleepWake(server, browser) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const ctx1 = await browser.newContext();
const page1 = await ctx1.newPage();
await joinAsStudent(page1, server.url, sid, "U2", "Sleeper");
// Capture the cookie so we can restore it in a fresh context (simulating phone-wake on same device)
const cookies = await ctx1.cookies();
const adminA = await adminOpenQuestion(server, jar, sid, 0, 10);
await page1.waitForSelector('text=2+2?', { timeout: 5000 });
await page1.click('button:has-text("B")');
await page1.waitForSelector('text=Submitted in', { timeout: 5000 });
// "Phone goes to sleep" — close the context entirely
await ctx1.close();
// Instructor closes, advances, opens Q2 (skip 1)
let cw = adminA.waitFor("question_closed", { timeoutMs: 5000 });
adminA.close();
await cw;
adminA.next();
await sleep(150);
let ow = adminA.waitFor("question_open", { timeoutMs: 5000 });
adminA.open(2, 10);
await ow;
// "Phone wakes" — fresh context with same persisted cookie
const ctx2 = await browser.newContext();
await ctx2.addCookies(cookies);
const page2 = await ctx2.newPage();
await page2.goto(`${server.url}/?sid=${sid}`);
// Should see Q3 (idx 2) text "Fastest sort?"
try {
await page2.waitForSelector('text=Fastest sort?', { timeout: 5000 });
expect(true, "ui_sleep_wake", "browser shows the LATEST question after wake");
} catch (e) {
expect(false, "ui_sleep_wake", "browser did NOT show latest question after wake", { err: e.message });
}
// Try to submit on the new question
try {
await page2.click('button:has-text("B")');
await page2.waitForSelector('text=Submitted in', { timeout: 5000 });
expect(true, "ui_sleep_wake", "post-wake submit acked in UI");
} catch (e) {
expect(false, "ui_sleep_wake", "post-wake submit failed in UI", { err: e.message });
}
adminA.disconnect();
await ctx2.close();
}
// Scenario 3: cookie tampering via document.cookie (browser cookie is HttpOnly so this should be a no-op)
async function uiCookieTamper(server, browser) {
const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const ctx = await browser.newContext();
const page = await ctx.newPage();
await joinAsStudent(page, server.url, sid, "U3", "Tamper");
// Confirm document.cookie does NOT see qz_student (HttpOnly)
const visible = await page.evaluate(() => document.cookie);
expect(!visible.includes("qz_student"), "ui_cookie_tamper", "qz_student is not visible to JS (HttpOnly verified)", { document_cookie: visible });
// Try to overwrite (browsers will silently ignore HttpOnly overwrite from JS)
await page.evaluate(() => { document.cookie = "qz_student=GARBAGE; path=/"; });
await page.reload();
// Should still be in lobby (cookie wasn't actually changed)
try {
await page.waitForSelector('text=Waiting for instructor', { timeout: 4000 });
expect(true, "ui_cookie_tamper", "tamper attempt did not log student out");
} catch (e) {
note("ui_cookie_tamper", `tamper may have succeeded (lobby not re-rendered): ${e.message}`);
}
await ctx.close();
}
// Scenario 4: two browser contexts with same student_id race (different cookies → 2 participants)
async function uiDupId(server, browser) {
const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const pA = await ctxA.newPage();
const pB = await ctxB.newPage();
await joinAsStudent(pA, server.url, sid, "DUPUI", "FirstBrowser");
await joinAsStudent(pB, server.url, sid, "DUPUI", "SecondBrowser");
expect(true, "ui_dup_id", "two browsers with same student_id both reach lobby");
await ctxA.close(); await ctxB.close();
}
const SCENARIOS = [
["ui_happy", uiHappy],
["ui_sleep_wake", uiSleepWake],
["ui_cookie_tamper", uiCookieTamper],
["ui_dup_id", uiDupId],
];
logLine("runner", "info", "starting ui stress", { seed: SEED, port: PORT, headless: HEADLESS });
const browser = await chromium.launch({ headless: HEADLESS });
let server = null;
try {
server = await bootServer({ port: PORT });
for (const [name, fn] of SCENARIOS) {
logLine(name, "start", `seed=${SEED}`);
try {
await fn(server, browser);
logLine(name, "ok", "scenario completed");
} catch (e) {
fail++;
failures.push({ scenario: name, msg: "uncaught", extra: { err: e.message } });
logLine(name, "fail", "uncaught exception", { err: e.message });
}
}
} finally {
if (server) await server.stop();
await browser.close();
}
logLine("runner", "summary", "done", { pass, fail, warn, failures });
process.exit(fail > 0 ? 1 : 0);

View File

@@ -1,2 +1,42 @@
def test_placeholder_api_admin():
assert True
from conftest import admin_login, create_quiz, create_session, join_student
def test_admin_login_required_and_quiz_session_crud(client, sample_pool):
assert client.get("/admin/").status_code == 401
assert client.post("/admin/login", json={"password": "wrong"}).status_code == 401
admin_login(client)
assert client.get("/admin/").status_code == 200
quiz_id = create_quiz(client, sample_pool)
quizzes = client.get("/admin/api/quizzes").json()["quizzes"]
assert any(item["id"] == quiz_id for item in quizzes)
response = client.post("/admin/api/sessions", json={"quiz_id": quiz_id})
assert response.status_code == 200
payload = response.json()
assert len(payload["sid"]) == 6
assert payload["join_url"].endswith(f"?sid={payload['sid']}")
assert payload["qr_url"].startswith("data:image/svg+xml;base64,")
sessions = client.get("/admin/api/sessions").json()["sessions"]
assert sessions[0]["sid"] == payload["sid"]
def test_quiz_upload_and_csv_export(client, sample_pool):
sid = create_session(client, sample_pool)
join_student(client, sid, "s1", "Student One")
csv_response = client.get(f"/admin/api/sessions/{sid}/csv")
assert csv_response.status_code == 200
assert "student_id,name,question_idx" in csv_response.text
upload = client.post(
"/admin/api/quizzes/upload",
files={"file": ("pool.json", __import__("json").dumps(sample_pool), "application/json")},
)
assert upload.status_code == 200
def test_invalid_quiz_and_session_errors(client):
admin_login(client)
assert client.post("/admin/api/quizzes", json={"pool_json": {"title": "bad", "questions": []}}).status_code == 400
assert client.post("/admin/api/sessions", json={"quiz_id": 999}).status_code == 404

View File

@@ -1,2 +1,29 @@
def test_placeholder_api_student():
assert True
from conftest import create_session, join_student
def test_session_metadata_join_me_and_stats(client, sample_pool):
sid = create_session(client, sample_pool)
metadata = client.get(f"/api/session/{sid}").json()
assert metadata["title"] == "Sample Quiz"
assert metadata["state"] == "lobby"
assert metadata["current_question_idx"] is None
join = join_student(client, sid, "s1", "First Name")
assert join["ok"] is True
assert "qz_student" in client.cookies
join_student(client, sid, "s1", "Updated Name")
me = client.get(f"/api/session/{sid}/me")
assert me.status_code == 200
assert me.json()["name"] == "Updated Name"
stats = client.get(f"/api/session/{sid}/stats").json()
assert stats["question_idx"] is None
assert stats["top5"][0]["name"] == "Updated Name"
def test_invalid_session_and_missing_cookie_paths(client):
assert client.get("/?sid=BAD").status_code == 200
assert "Ask your instructor" in client.get("/?sid=BAD").text
assert client.get("/api/session/BAD").status_code == 404
assert client.get("/api/session/BAD/me").status_code == 401

View File

@@ -1,2 +1,33 @@
def test_placeholder_auth():
assert True
from fastapi import HTTPException
from app import auth
from app.config import Settings
def test_student_cookie_signing_roundtrip():
settings = Settings(secret_key="secret", public_url="http://testserver")
token = auth.sign_student(settings, "ABC123", "s1", "Ada", "cookie-id")
payload = auth.loads_cookie(settings, token)
assert payload == {"sid": "ABC123", "student_id": "s1", "name": "Ada", "cookie_id": "cookie-id"}
def test_tampered_cookie_rejected():
settings = Settings(secret_key="secret")
token = auth.sign_admin(settings)
assert auth.loads_cookie(settings, token + "tamper") is None
def test_admin_password_success_and_failure():
settings = Settings(secret_key="secret", admin_password="pw")
assert auth.verify_admin_password(settings, "pw")
assert not auth.verify_admin_password(settings, "wrong")
assert not auth.verify_admin_password(Settings(secret_key="secret"), "pw")
def test_serializer_requires_secret():
try:
auth.sign_admin(Settings(secret_key=None))
except HTTPException as exc:
assert exc.status_code == 500
else:
raise AssertionError("Expected missing secret to fail")

View File

@@ -1,2 +1,17 @@
def test_placeholder_csv_export():
assert True
from conftest import create_session, join_student
def test_csv_export_contains_one_row_per_submission(client, sample_pool):
sid = create_session(client, sample_pool)
join_student(client, sid, "s1", "Student One")
rooms = client.app.state.rooms
client.portal.call(rooms.open_question, sid, 0, 2)
ack = client.portal.call(rooms.submit_answer, sid, "s1", 0, "B")
assert ack["type"] == "submit_ack"
client.portal.call(rooms.close_question, sid)
response = client.get(f"/admin/api/sessions/{sid}/csv")
lines = response.text.strip().splitlines()
assert lines[0] == "sid,student_id,name,question_idx,answer,elapsed_ms,score,status"
assert len(lines) == 2
assert ",s1,Student One,0,B," in lines[1]

View File

@@ -1,2 +1,35 @@
def test_placeholder_late_join():
assert True
from fastapi.testclient import TestClient
from conftest import create_session, join_student
def test_late_join_during_open_gets_reduced_remaining_and_can_score(client, sample_pool):
sid = create_session(client, sample_pool)
rooms = client.app.state.rooms
client.portal.call(rooms.open_question, sid, 0, 2)
late = TestClient(client.app)
with late:
join_student(late, sid, "late", "Late Student")
with late.websocket_connect(f"/ws/student/{sid}") as ws:
assert ws.receive_json()["state"] == "question_open"
opened = ws.receive_json()
assert opened["type"] == "question_open"
assert 0 < opened["remaining_ms"] <= 2000
ws.send_json({"type": "submit", "question_idx": 0, "answer": "B"})
assert ws.receive_json()["score"] > 0
def test_join_after_closed_gets_missed_row(client, sample_pool):
sid = create_session(client, sample_pool)
rooms = client.app.state.rooms
client.portal.call(rooms.open_question, sid, 0, 1)
client.portal.call(rooms.close_question, sid)
late = TestClient(client.app)
with late:
join_student(late, sid, "late", "Late Student")
me = late.get(f"/api/session/{sid}/me").json()
assert me["submissions"][0]["question_idx"] == 0
assert me["submissions"][0]["status"] == "missed"
assert me["submissions"][0]["score"] == 0

View File

@@ -1,2 +1,49 @@
def test_placeholder_load_simulation():
assert True
import time
from conftest import create_session, join_student
def test_load_simulation_50_students_full_quiz_and_autoclose(client, sample_pool):
sid = create_session(client, sample_pool)
rooms = client.app.state.rooms
sockets = []
try:
for idx in range(50):
join_student(client, sid, f"s{idx:02d}", f"Student {idx:02d}")
ws = client.websocket_connect(f"/ws/student/{sid}").__enter__()
sockets.append(ws)
assert ws.receive_json()["type"] == "state"
for question_idx in range(5):
client.portal.call(rooms.open_question, sid, question_idx, 1)
for ws in sockets:
assert ws.receive_json()["type"] == "question_open"
for idx, ws in enumerate(sockets):
answer = sample_pool["questions"][question_idx]["correct"] if idx % 3 else "A"
ws.send_json({"type": "submit", "question_idx": question_idx, "answer": answer})
assert ws.receive_json()["type"] == "submit_ack"
if question_idx == 4:
started = time.monotonic()
for ws in sockets:
message = ws.receive_json()
assert message["type"] == "question_closed"
assert time.monotonic() - started < 2
else:
client.portal.call(rooms.close_question, sid)
for ws in sockets:
assert ws.receive_json()["type"] == "question_closed"
client.portal.call(rooms.next_question, sid)
for ws in sockets:
assert ws.receive_json()["type"] == "between_questions"
client.portal.call(rooms.end_session, sid)
for ws in sockets:
assert ws.receive_json()["type"] == "session_ended"
csv_lines = client.get(f"/admin/api/sessions/{sid}/csv").text.strip().splitlines()
assert len(csv_lines) == 1 + 50 * 5
stats = client.get(f"/api/session/{sid}/stats?question_idx=4").json()
assert stats["top5"]
finally:
for ws in sockets:
ws.__exit__(None, None, None)

View File

@@ -1,2 +1,37 @@
def test_placeholder_pool():
assert True
import pytest
from app.pool import PoolValidationError, get_question, parse_pool_json, public_question_payload, question_time_limit
def test_pool_validation_accepts_well_formed_pool(sample_pool):
pool = parse_pool_json(sample_pool)
assert pool["title"] == "Sample Quiz"
assert pool["score_fn"] == "linear_decay"
assert question_time_limit(pool, 0) == 2
assert get_question(pool, 0)["correct"] == "B"
public = public_question_payload(pool, 0)
assert "correct" not in public
assert public["options"]["A"] == "Alpha"
@pytest.mark.parametrize(
"mutator, message",
[
(lambda p: p.pop("title"), "title"),
(lambda p: p.update({"questions": []}), "at least one"),
(lambda p: p["questions"][0].pop("text"), "text"),
(lambda p: p["questions"][0].update({"options": {"A": "x"}}), "options"),
(lambda p: p["questions"][0].update({"correct": "E"}), "correct"),
(lambda p: p.update({"score_fn": "missing"}), "Unknown"),
(lambda p: p.update({"time_limit_default": 0}), "positive"),
],
)
def test_pool_validation_rejects_invalid_shapes(sample_pool, mutator, message):
mutator(sample_pool)
with pytest.raises(PoolValidationError, match=message):
parse_pool_json(sample_pool)
def test_pool_validation_rejects_invalid_json():
with pytest.raises(PoolValidationError, match="Invalid JSON"):
parse_pool_json("{bad")

View File

@@ -1,2 +1,22 @@
def test_placeholder_reconnect():
assert True
from conftest import create_session, join_student
def test_reconnect_replays_existing_submit_ack(client, sample_pool):
sid = create_session(client, sample_pool)
join_student(client, sid, "s1", "Student One")
rooms = client.app.state.rooms
client.portal.call(rooms.open_question, sid, 0, 2)
with client.websocket_connect(f"/ws/student/{sid}") as ws:
assert ws.receive_json()["type"] == "state"
assert ws.receive_json()["type"] == "question_open"
ws.send_json({"type": "submit", "question_idx": 0, "answer": "B"})
ack = ws.receive_json()
assert ack["type"] == "submit_ack"
with client.websocket_connect(f"/ws/student/{sid}") as ws:
assert ws.receive_json()["type"] == "state"
assert ws.receive_json()["type"] == "question_open"
replay = ws.receive_json()
assert replay["type"] == "submit_ack"
assert replay["score"] == ack["score"]

View File

@@ -1,2 +1,26 @@
def test_placeholder_scoring():
assert True
from app.scoring import SCORE_FNS
def test_linear_decay_values():
fn = SCORE_FNS["linear_decay"]
assert fn(True, 0, 60_000) == 1000
assert fn(True, 30_000, 60_000) == 750
assert fn(True, 60_000, 60_000) == 500
assert fn(True, 90_000, 60_000) == 500
assert fn(False, 0, 60_000) == 0
def test_flat_values():
fn = SCORE_FNS["flat"]
assert fn(True, 0, 60_000) == 1000
assert fn(True, 60_000, 60_000) == 1000
assert fn(True, 90_000, 60_000) == 1000
assert fn(False, 0, 60_000) == 0
def test_exponential_decay_values():
fn = SCORE_FNS["exponential_decay"]
assert fn(True, 0, 60_000) == 1000
assert 560 < fn(True, 60_000, 60_000) < 570
assert fn(True, 90_000, 60_000) == fn(True, 60_000, 60_000)
assert fn(False, 0, 60_000) == 0

View File

@@ -1,2 +1,45 @@
def test_placeholder_state_machine():
assert True
from conftest import create_session, join_student
def test_full_lifecycle_with_three_students(client, sample_pool):
sid = create_session(client, sample_pool)
rooms = client.app.state.rooms
sockets = []
for idx in range(3):
join_student(client, sid, f"s{idx}", f"Student {idx}")
ws = client.websocket_connect(f"/ws/student/{sid}").__enter__()
sockets.append(ws)
assert ws.receive_json()["state"] == "lobby"
try:
client.portal.call(rooms.open_question, sid, 0, 2)
for ws in sockets:
assert ws.receive_json()["type"] == "question_open"
for idx, ws in enumerate(sockets):
ws.send_json({"type": "submit", "question_idx": 0, "answer": "B" if idx < 2 else "A"})
assert ws.receive_json()["type"] == "submit_ack"
client.portal.call(rooms.close_question, sid)
for ws in sockets:
assert ws.receive_json()["type"] == "question_closed"
session = client.portal.call(rooms.get_session, sid)
assert session["state"] == "question_closed"
client.portal.call(rooms.next_question, sid)
for ws in sockets:
assert ws.receive_json()["type"] == "between_questions"
assert client.portal.call(rooms.get_session, sid)["state"] == "between_questions"
client.portal.call(rooms.open_question, sid, 1, 2)
for ws in sockets:
assert ws.receive_json()["type"] == "question_open"
client.portal.call(rooms.end_session, sid)
for ws in sockets:
message = ws.receive_json()
assert message["type"] in {"question_closed", "session_ended"}
if message["type"] == "question_closed":
assert ws.receive_json()["type"] == "session_ended"
assert client.portal.call(rooms.get_session, sid)["state"] == "finished"
finally:
for ws in locals().get("sockets", []):
ws.__exit__(None, None, None)

View File

@@ -1,2 +1,38 @@
def test_placeholder_ws_admin():
assert True
import pytest
from starlette.websockets import WebSocketDisconnect
from conftest import admin_login, create_session, join_student
def test_instructor_ws_requires_admin_cookie(client, sample_pool):
sid = create_session(client, sample_pool)
client.cookies.clear()
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(f"/ws/instructor/{sid}"):
pass
assert exc.value.code == 4001
def test_instructor_controls_transition_and_broadcast(client, sample_pool):
sid = create_session(client, sample_pool)
join_student(client, sid, "s1", "Student One")
admin_login(client)
with client.websocket_connect(f"/ws/student/{sid}") as student_ws:
assert student_ws.receive_json()["type"] == "state"
with client.websocket_connect(f"/ws/instructor/{sid}") as admin_ws:
assert admin_ws.receive_json()["type"] == "state"
assert admin_ws.receive_json()["type"] == "lobby_update"
admin_ws.send_json({"type": "open_question", "question_idx": 0, "time_limit": 2})
student_open = student_ws.receive_json()
assert student_open["type"] == "question_open"
admin_open = admin_ws.receive_json()
assert admin_open["type"] == "question_open"
assert admin_ws.receive_json()["type"] == "live_histogram"
admin_ws.send_json({"type": "close_question"})
assert student_ws.receive_json()["type"] == "question_closed"
messages = [admin_ws.receive_json(), admin_ws.receive_json()]
assert {msg["type"] for msg in messages} == {"question_closed", "full_leaderboard"}
admin_ws.send_json({"type": "next"})
assert student_ws.receive_json()["type"] == "between_questions"

View File

@@ -1,2 +1,37 @@
def test_placeholder_ws_student():
assert True
import pytest
from starlette.websockets import WebSocketDisconnect
from conftest import create_session, join_student
def test_student_ws_requires_cookie(client, sample_pool):
sid = create_session(client, sample_pool)
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(f"/ws/student/{sid}"):
pass
assert exc.value.code == 4001
def test_student_ws_initial_state_submit_and_closed_reject(client, sample_pool):
sid = create_session(client, sample_pool)
join_student(client, sid, "s1", "Student One")
with client.websocket_connect(f"/ws/student/{sid}") as ws:
state = ws.receive_json()
assert state["type"] == "state"
assert state["state"] == "lobby"
rooms = client.app.state.rooms
client.portal.call(rooms.open_question, sid, 0, 2)
opened = ws.receive_json()
assert opened["type"] == "question_open"
ws.send_json({"type": "submit", "question_idx": 0, "answer": "B"})
ack = ws.receive_json()
assert ack["type"] == "submit_ack"
assert ack["score"] > 0
client.portal.call(rooms.close_question, sid)
closed = ws.receive_json()
assert closed["type"] == "question_closed"
ws.send_json({"type": "submit", "question_idx": 0, "answer": "B"})
error = ws.receive_json()
assert error["code"] == "not_open"