elemes/docs/12-quiz-integrity.md

6.5 KiB

12. Quiz Integrity — Strict Focus-Loss Anti-Cheat

Kuis diakhiri secara otomatis saat browser mendeteksi siswa meninggalkan halaman kuis. Kebijakan ini strict: kehilangan fokus pertama ketika kuis aktif langsung mengakhiri kuis dengan penalti (soal belum dijawab dianggap salah), menyimpan alasan pelanggaran di PostgreSQL, dan tidak ada kesempatan melanjutkan setelah kembali fokus.

Baca juga: 07. Quiz Authoring untuk format soal.

Policy

Kejadian Dampak
Selesai kuis normal (semua soal dijawab) 1 attempt submitted, tanpa pelanggaran
Tombol "Keluar Kuis" 1 attempt terminated/user_exit, skor penalti
Navigasi SPA (pindah halaman) 1 attempt terminated/spa_navigation, skor penalti
Switch tab / minimize / switch app (visibility hidden) 1 attempt terminated/focus_lostpelanggaran
Window desktop kehilangan fokus (blur) 1 attempt terminated/focus_lostpelanggaran
Reload / tutup tab 1 attempt terminated/page_unload via sendBeacon
Kembali fokus setelah terminated Tidak ada efek — kuis tidak kembali aktif

Event yang memicu termination hanya dipasang saat kuis aktif dan dibersihkan saat kuis selesai/unmount. Event ganda (mis. visibilitychange lalu blur, atau blur lalu beforeunload) hanya memfinalisasi satu attempt karena seluruh exit path memakai attempt_id yang sama dan finalisasi idempotent.

Kuis yang berakhir karena focus_lost tidak menampilkan pembahasan soal pada ringkasan (skor tetap tampil; lihat shouldShowQuizReview di frontend/src/lib/services/quiz-integrity.ts). Alasan lain (user_exit, spa_navigation, page_unload, completed) tetap menampilkan pembahasan.

Arsitektur

  • LessonManager (frontend/src/routes/lesson/[slug]/lesson.svelte.ts) adalah single source of truth: saat startQuiz(), dibuat attempt_id (crypto.randomUUID()); seluruh exit path memanggil terminateQuiz(reason) yang idempotent dan menyusun payload attempt secara synchronous.
  • Event visibilitychange + blur dipasang di +page.svelte lewat satu $effect yang hanya aktif ketika isQuizMode.
  • Exit path yang bisa membuat browser suspend (focus_lost, page_unload) memakai navigator.sendBeacon terlebih dahulu — tidak ada await dari event lifecycle. Exit biasa (user_exit, spa_navigation, completed) memakai fetch agar response dapat ditangani; fallback legacy track-progress hanya dipakai bila endpoint attempt tidak dapat dijangkau (tanpa attempt kedua).
  • Backend POST /api/quiz-attempts/submit (lihat routes/quiz_attempts.py) memvalidasi payload, menyimpan audit attempt di tabel quiz_attempts, dan meng-update student_progress dalam satu transaksi. Retry dengan attempt_id sama → respons idempotent tanpa menggandakan row; record pertama tidak pernah ditimpa. One-attempt policy dijaga unique (user_id, lesson_id).

Data contract (payload klien)

{
  "attempt_id": "uuid-canonical",
  "token": "student-token",
  "lesson_name": "quiz_test",
  "status": "terminated",
  "termination_reason": "focus_lost",
  "score": "2/4",
  "occurred_at": "2026-08-09T14:04:44.000Z",
  "started_at": "2026-08-09T14:03:00.000Z",
  "visibility_event_count": 1
}

Aturan status: completedsubmitted + termination_reason: null; focus_lost wajib terminated. Nilai reason diizinkan: focus_lost, spa_navigation, page_unload, user_exit, completed (reserved).

Apa yang bisa dan tidak bisa dideteksi browser

Sinyal Status
Switch tab (desktop) Terdeteksi (visibilitychangehidden)
Alt-Tab / minimize (desktop) Terdeteksi (blur / visibilitychange)
Switch app (Chrome Android) Terdeteksi bila browser menerbitkan visibilitychange hidden
iOS Safari switch app / tab Tidak selalu terdeteksi — kebijakan tampil tapi tidak menjanjikan deteksi penuh
Blur karena browser chrome / permission prompt / dialog OS False positive — kebijakan strict menerima trade-off ini; uji di device target
Interaksi dengan iframe embed di dalam soal kuis (mis. Canva/YouTube/simulator) False positive — fokus pindah ke iframe memicu blur window → termination; hindari embed interaktif di soal bila hal ini tidak diterima
Siswa mematikan JavaScript / memakai browser automation Tidak terdeteksi — ini lapisan deterrence & audit, bukan proctoring kriptografis

Browser tidak menyediakan API yang membuktikan aplikasi mana yang dibuka siswa. user_agent disimpan hanya untuk diagnosis device/browser — bukan dasar hukuman tambahan, dan tidak ada isi layar atau daftar aplikasi yang direkam.

Laporan guru

Laporan progress (/progress-report.json) menambahkan field terpisah per lesson tanpa mengubah kontrak status lama:

{
  "quiz_test": "2/4",
  "quiz_test_attempt_status": "terminated",
  "quiz_test_termination_reason": "focus_lost",
  "quiz_test_has_violation": true,
  "quiz_test_attempt_finished_at": "2026-08-09T14:04:44+00:00"
}
  • Badge ⚠ Pelanggaran muncul di cell kuis; tooltip memuat reason dan waktu termination (bukan token).
  • Tombol reset guru menghapus progress dan attempt (one-attempt) sehingga siswa dapat mengulang setelah reset.
  • Export CSV laporan ikut menyertakan kolom audit di atas.

Fallback & kegagalan

  • sendBeacon gagal → best-effort fetch keepalive hanya saat document masih visible; tidak ada blokir UI. Kegagalan di luar itu terdokumentasi sebagai kehilangan audit (progress tetap aman karena endpoint atomic).
  • Endpoint attempt tidak terjangkau (server down) saat exit biasa → fallback track-progress agar skor tetap tersimpan; attempt tidak dibuat dua kali.

Privacy

  • Token mentah tidak pernah di-log, disimpan, atau dipantulkan ke response — backend hanya menyimpan HMAC digest dan id anonim.
  • Metadata attempt tidak berisi jawaban soal maupun isi layar.

Deployment

Migration baru: migrations/versions/0002_quiz_attempts.py (tabel quiz_attempts + constraint reason/status + index). Existing student_progress tetap kompatibel; siswa yang sudah menyelesaikan kuis sebelum fitur ini tidak memiliki row attempt, dan gate one-attempt tetap memakai lesson_progress_status sehingga mereka tidak bisa mengulang tanpa reset guru.

Hardening lanjutan (fase terpisah)

Untuk ujian high-stakes, jangan percaya skor client: simpan question IDs/order & jawaban di server, atau gunakan attempt token/session yang dikeluarkan server. Berbeda scope dari event focus-loss dan sengaja tidak termasuk dalam implementasi ini.