Nilai Nyata Open Source: Ketika Komunitas Menemukan Celah yang Terlewat
Salah satu hal paling memuaskan dari merilis Laravel E-Learning & CBT Platform sebagai proyek open-source setelah rilis produksi adalah melihat developer dan pengajar lain mencoba, menguji, dan mengeksplorasi aplikasi secara langsung di dunia nyata.
Tidak lama setelah repository dipublikasikan, seorang kontributor dan praktisi keamanan open-source, @ze0expl01t, membuat tiket GitHub Issue #16: "Bug Kritis di ExamStart.php".
Laporan tersebut menyoroti tiga kelemahan mendasar dalam siklus pelaksanaan ujian daring (CBT):
- Kalkulasi Nilai Essay Cacat: Nilai akhir siswa langsung dipotong drastis dan dinyatakan "Belum Lulus" saat ujian memiliki soal essay.
- Tanpa Guard Validasi Masuk Ujian: Siswa dapat mengakses ujian di luar kelasnya, ujian yang belum dipublikasikan, atau mengulang ujian tanpa izin.
- Status Tombol UI Siswa Tidak Sinkron: Halaman Dashboard dan Daftar Ujian tetap menampilkan tombol statis tanpa memedulikan status attempt siswa yang sedang berjalan atau sudah selesai.
Di Part 9 dari seri ini, kita membedah post-mortem teknis dari issue tersebut: menelusuri akar masalah setiap bug, meninjau perbaikan komprehensif pada Pull Request #17, serta membangun workflow CI & Security otomatis dengan GitHub Actions agar regresi serupa tidak pernah terulang di masa mendatang.
Masalah 1: Bug Nilai Essay Prematur & Vonis Tidak Lulus Palsu
Alur Kode yang Bermasalah
Pada Part 5: Membangun Sistem Ujian, sistem dirancang untuk mendukung soal pilihan ganda (PG) dan soal essay. Soal PG dinilai otomatis secara instan oleh sistem, sedangkan soal essay memerlukan koreksi manual dan input poin oleh guru di portal penilaian.
Berikut adalah logika awal method finishExam() pada app/Livewire/Student/ExamStart.php:
PHP
// ❌ KODE LAMA BERMASALAH
public function finishExam()
{
$questions = $this->examination->questions;
$totalPoints = 0;
$earnedPoints = 0;
$hasEssay = false;
foreach ($questions as $q) {
$totalPoints += $q->points;
$answer = $this->answers[$q->id] ?? null;
if ($q->question_type === 'essay') {
$hasEssay = true;
// Essay: tidak auto-grade, biarkan points_earned = 0
} elseif ($answer && $q->question_type === 'multiple_choice') {
$isCorrect = strtoupper(trim($answer)) === strtoupper(trim($q->correct_answer ?? ''));
ExamAnswer::where('exam_attempt_id', $this->attempt->id)
->where('question_id', $q->id)
->update(['is_correct' => $isCorrect, 'points_earned' => $isCorrect ? $q->points : 0]);
if ($isCorrect) {
$earnedPoints += $q->points;
}
}
}
// 💥 BUG: $totalPoints menjumlahkan essay, tapi $earnedPoints belum dinilai guru!
$score = $totalPoints > 0 ? round(($earnedPoints / $totalPoints) * 100) : 0;
$status = $hasEssay ? 'needs_grading' : 'completed';
$this->attempt->update([
'finished_at' => now(),
'score' => $score,
'is_passed' => $score >= $this->examination->passing_score,
'status' => $status,
]);
}
Mengapa Bug Ini Sangat Fatal?
Bayangkan sebuah ujian dengan komposisi:
- 5 soal pilihan ganda (masing-masing 10 poin = 50 poin)
- 2 soal essay (masing-masing 25 poin = 50 poin)
- Total Poin Maksimal: 100 poin
- Batas Kelulusan (KKM): 75 poin
Siswa yang menjawab benar seluruh soal pilihan ganda (50 poin) dan menulis jawaban essay dengan sangat lengkap akan langsung divonis saat menekan "Selesaikan Ujian":
$earnedPointsbernilai50.$totalPointsbernilai100.$scoredihitung sebagairound((50 / 100) * 100) = 50.$is_passeddievaluasi50 >= 75➔false(Gagal / Tidak Lulus!).- Nilai
50langsung ditampilkan kepada siswa, menimbulkan kepanikan seolah mereka gagal dalam ujian padahal jawaban essay mereka sama sekali belum diperiksa oleh guru! - Lebih parah lagi, enum database awal pada kolom
statustabelexam_attemptshanya mengizinkan['in_progress', 'completed', 'force_finished'], sehingga penetapan'needs_grading'berisiko menimbulkan database exception di MySQL dengan mode strict.
Solusi di PR #17
-
Pembaruan Migrasi Database: Enum status pada tabel
exam_attemptsdiperbarui untuk mendukung status'needs_grading':PHP
// database/migrations/2026_02_22_000011_create_exam_attempts_table.php $table->enum('status', ['in_progress', 'completed', 'needs_grading', 'force_finished']) ->default('in_progress'); -
Pemisahan Siklus Penilaian (Decoupled Scoring): Jika ujian memiliki komponen essay, kalkulasi nilai angka ditangguhkan sepenuhnya (deferred):
PHP
// ✅ KODE BARU DI PR #17 (ExamStart.php) if ($hasEssay) { $status = 'needs_grading'; $score = null; // Tidak ada nilai angka prematur! $isPassed = false; $message = "Ujian selesai! Soal essay akan dinilai oleh guru."; } else { $status = 'completed'; $score = $totalPoints > 0 ? round(($earnedPoints / $totalPoints) * 100) : 0; $isPassed = $score >= $this->examination->passing_score; $message = "Ujian selesai! Nilai Anda: {$score}"; } $this->attempt->update([ 'finished_at' => now(), 'score' => $score, 'is_passed' => $isPassed, 'status' => $status, ]); -
Penyimpanan Jawaban Andal dengan
updateOrCreate: Seluruh jawaban siswa disimpan secara persisten tanpa bergantung pada asumsi record yang sudah ada:PHP
ExamAnswer::updateOrCreate( ['exam_attempt_id' => $this->attempt->id, 'question_id' => $q->id], [ 'answer_text' => $answer, 'answered_at' => ($answer !== null && $answer !== '') ? now() : null, 'points_earned' => 0, 'is_correct' => null, // Menunggu koreksi guru ] );
Masalah 2: Hilangnya Guard Validasi Akses & Pengulangan Ujian
Celah Akses
Pada implementasi awal, komponen ExamStart.php berasumsi bahwa siapa pun yang membuka URL /student/exams/{examination} berhak mengerjakan ujian. Tidak ada pengecekan defensive pada mount() maupun startExam().
Akibatnya:
- Siswa kelas A bisa membuka dan mengerjakan ujian yang dijadwalkan untuk kelas B (
classroom_id). - Siswa bisa mengakses ujian berstatus draf (
status !== 'published'). - Siswa bisa memulai ujian sebelum jadwal mulai (
start_at) atau setelah batas akhir (end_at). - Siswa dapat mengulang ujian sesuka hati meskipun opsi pengulangan (
allow_retry) dimatikan. - Tombol mulai yang diklik ganda atau di-refresh dapat membuat record attempt duplikat di database.
Implementasi Guard Bertingkat
Pada PR #17, kami menyematkan guard ketat pada startExam() dan mount():
PHP
public function startExam(): void
{
$student = auth()->user()?->student;
// 1. Guard Kesesuaian Kelas
if (!$student || $this->examination->classroom_id !== $student->classroom_id) {
session()->flash('error', 'Anda tidak memiliki akses ke ujian ini.');
$this->redirect(route('student.exams'), navigate: true);
return;
}
// 2. Guard Status Publikasi
if ($this->examination->status !== 'published') {
session()->flash('error', 'Ujian belum dipublikasikan atau sudah ditutup.');
$this->redirect(route('student.exams'), navigate: true);
return;
}
// 3. Guard Jadwal Pelaksanaan
if (now()->lt($this->examination->start_at) || now()->gt($this->examination->end_at)) {
session()->flash('error', 'Waktu pelaksanaan ujian tidak valid atau telah berakhir.');
$this->redirect(route('student.exams'), navigate: true);
return;
}
// 4. Lanjutkan Attempt yang Ada (Mencegah Duplikasi)
$existing = ExamAttempt::where('examination_id', $this->examination->id)
->where('student_id', $student->id)
->where('status', 'in_progress')
->first();
if ($existing) {
$this->attempt = $existing;
$this->examStarted = true;
$this->loadAnswers();
return;
}
// 5. Guard Izin Mengulang (allow_retry)
$finishedAttemptsCount = ExamAttempt::where('examination_id', $this->examination->id)
->where('student_id', $student->id)
->whereIn('status', ['completed', 'needs_grading', 'force_finished'])
->count();
if ($finishedAttemptsCount > 0 && !$this->examination->allow_retry) {
session()->flash('error', 'Anda sudah menyelesaikan ujian ini dan tidak dapat mengulang.');
$this->redirect(route('student.exams'), navigate: true);
return;
}
// 6. Pembuatan Attempt Baru yang Bersih
$attemptNumber = ExamAttempt::where('examination_id', $this->examination->id)
->where('student_id', $student->id)
->count() + 1;
$this->attempt = ExamAttempt::create([
'examination_id' => $this->examination->id,
'student_id' => $student->id,
'started_at' => now(),
'attempt_number' => $attemptNumber,
'status' => 'in_progress',
]);
$this->examStarted = true;
$this->loadAnswers();
}
Penanganan Otomatis Durasi Habis saat Resume
Bagaimana jika seorang siswa menutup browser atau mematikan laptop, lalu membuka kembali ujian setelah durasi 60 menitnya habis?
Di method mount(), waktu tenggat dihitung dari batas terkecil antara durasi attempt dan end_at ujian:
PHP
$durationDeadline = $this->attempt->started_at->copy()->addMinutes($this->examination->duration_minutes);
$deadline = $durationDeadline->lt($this->examination->end_at) ? $durationDeadline : $this->examination->end_at;
if (now()->gte($deadline)) {
$this->finishExam();
return;
}
Jika waktu habis, method finishExam() otomatis dipanggil untuk menyimpan jawaban yang sempat diisi, mengunci attempt, dan mengarahkan siswa ke dashboard secara elegan.
Masalah 3: Sinkronisasi Status Tombol UI Siswa
Di Dashboard dan Daftar Ujian, tampilan sebelumnya menampilkan tombol statis "Mulai Ujian" selama tanggal ujian masih aktif, tidak peduli apakah siswa sudah mengumpulkan ujian atau sedang mengerjakannya.
Solusi di Dashboard.php & Exams.php
Query Livewire dioptimalkan dengan eager loading attempt terbaru milik siswa:
PHP
$upcomingExams = Examination::with([
'subject',
'attempts' => fn ($q) => $q->where('student_id', $studentId)->latest()
])
->where('classroom_id', $classroomId)
->where('status', 'published')
->where('end_at', '>', now())
->orderBy('start_at')
->get();
Di template Blade (dashboard.blade.php), tombol aksi kini secara cerdas beradaptasi dengan state attempt siswa:
| Kondisi Attempt Siswa | Tampilan UI | Warna / Tipe |
|---|---|---|
status === 'in_progress' | Lanjutkan Ujian → | Tombol oranye (amber) |
Selesai & allow_retry == true | Ulangi Ujian → | Tombol outline biru |
Selesai & status === 'needs_grading' | ⏳ Menunggu Penilaian | Badge ungu |
Selesai & status === 'completed' | ✓ Selesai (Nilai) | Badge hijau |
| Belum mulai & Jadwal aktif | Mulai Ujian → | Tombol primer biru |
| Belum mulai & Belum waktu mulai | Mulai [Waktu] | Badge abu-abu |
BLADE
@php
$latestAttempt = $exam->attempts->first();
@endphp
@if ($latestAttempt && $latestAttempt->status === 'in_progress')
<a href="{{ route('student.exams.start', $exam) }}" class="btn-amber">
Lanjutkan Ujian →
</a>
@elseif ($latestAttempt && $exam->allow_retry)
<a href="{{ route('student.exams.start', $exam) }}" class="btn-outline-blue">
Ulangi Ujian →
</a>
@elseif ($latestAttempt && $latestAttempt->status === 'needs_grading')
<span class="badge-purple">
⏳ Menunggu Penilaian
</span>
@elseif ($latestAttempt)
<span class="badge-green">
✓ Selesai ({{ $latestAttempt->score ?? '-' }})
</span>
@elseif ($exam->start_at <= now())
<a href="{{ route('student.exams.start', $exam) }}" class="btn-primary">
Mulai Ujian →
</a>
@else
<span class="badge-gray">
Mulai {{ $exam->start_at->diffForHumans() }}
</span>
@endif
Masalah 4: Menjaga Kualitas dengan Otomasi CI & Security Pipeline
Pelajaran berharga dari Issue #16 adalah pengujian manual untuk puluhan kombinasi role, attempt, dan status sangat rawan terlewat. Sebagai langkah preventif jangka panjang, PR #17 menambahkan workflow otomatis di .github/workflows/ci.yml.
Pipeline ini berjalan pada setiap push ke branch utama, setiap Pull Request, dan dijadwalkan mingguan setiap hari Senin pukul 02:00 UTC.
Sorotan Fitur Pipeline
-
Caching Dependensi Deterministik: Menggunakan direktori cache Composer dinamis (
composer config cache-files-dir) memangkas waktu build dari ~3 menit menjadi di bawah 40 detik. -
Audit Kerentanan Otomatis: Dependensi backend (
composer audit --locked) dan frontend (npm audit --audit-level=high) diperiksa terhadap database CVE terkini. -
Pendeteksi File Sensitif & Secret Leak: Skrip shell mendeteksi apakah ada file kredensial privat yang tidak sengaja ter-commit ke repository:
BASH
SENSITIVE_FILES=$(git ls-files | grep -E "(^|/)(\.env(\.(local|production|staging|testing))?|.*\.pem|.*\.key|id_rsa|id_ed25519)$" | grep -v "\.example" || true) if [ -n "$SENSITIVE_FILES" ]; then echo "❌ Masalah Keamanan: File sensitif terdeteksi di git tracking!" exit 1 fi -
Standarisasi Gaya Kode: Laravel Pint memastikan seluruh kontribusi mengikuti standar PSR-12 dan konvensi Laravel secara rapi.
Pelajaran untuk Maintainer Proyek Open Source
Menangani Issue #16 memperkuat beberapa prinsip penting dalam mengelola proyek open source:
- Apresiasi dan Komunikasi Cepat: Ketika ada pengguna yang meluangkan waktu membuat laporan issue yang jelas, tanggapi dengan apresiasi dan transparansi. Jelaskan estimasi perbaikan dan branch yang sedang dikerjakan.
- Lihat Masalah Secara Sistemik:
Issue #16 awalnya fokus pada
ExamStart.php. Namun, penelusuran menunjukkan bahwa status attempt juga memengaruhi dashboard siswa, daftar ujian, skema database, dan monitor guru. Memperbaiki akar sistemik jauh lebih kokoh daripada menambal permukaan saja. - Setiap Bug Harus Melahirkan Otomasi (Test / CI): Ubah setiap bug yang ditemukan menjadi skenario pengujian dan perlindungan CI, sehingga tidak ada regresi yang lolos tanpa sengaja di masa depan.
Ringkasan Perubahan
| Komponen | Sebelum PR #17 | Sesudah PR #17 |
|---|---|---|
| Penilaian Essay | Poin essay masuk pembagi; nilai dipotong prematur | Status needs_grading; nilai di-set null menunggu koreksi guru |
| Skema Database | Status enum: ['in_progress', 'completed', 'force_finished'] | Ditambahkan status 'needs_grading' pada enum |
| Guard Akses Ujian | Tanpa validasi kelas, waktu, maupun izin retry | Guard ketat di mount() & startExam(); auto-finish jika durasi habis |
| Tampilan UI Siswa | Tombol statis "Mulai Ujian" | Tombol dinamis: Lanjutkan, Ulangi, ⏳ Menunggu Penilaian, ✓ Selesai |
| Monitor Ujian Guru | Crash Undefined array key "perlu_dinilai" saat disortir | Ditambahkan mapping sorting aman dengan fallback ?? 99 |
| CI / CD Pipeline | Belum ada | GitHub Actions lengkap: Testing, Build Vite, Pint, Audit Keamanan, & Secret Scanner |
Terima kasih banyak untuk @ze0expl01t atas kontribusi dan laporan Issue #16 yang membuat platform ini semakin andal dan siap pakai di lingkungan pendidikan!
Lanjut ke Part 10: Penguatan Keamanan & Celah IDOR →
🔗 Referensi & Sumber Daya:
