The Beauty of Open Source: When Community Finds What You Missed
One of the most rewarding aspects of open-sourcing our Laravel E-Learning & CBT Platform after our initial production release was having real developers and educators clone the repository, test the application, and push it beyond our initial assumptions.
Shortly after publishing the codebase, an open-source contributor and security researcher, @ze0expl01t, submitted GitHub Issue #16: "Bug Kritis di ExamStart.php".
The issue reported three critical flaws in the Computer-Based Testing (CBT) engine:
- Flawed Essay Scoring: Premature score calculation in
ExamStart.phpwhen exams contained essay questions. - Missing Exam Start Guards: Lack of student classroom, schedule, and retry validation.
- De-synchronized Student UI: The student dashboard and exams list displayed static buttons without accounting for active or completed attempt states.
In this 9th part of our series, we'll dive deep into the technical post-mortem: dissecting each bug, understanding why it escaped our initial tests, reviewing the fixes delivered in Pull Request #17, and implementing an automated GitHub Actions CI & Security workflow to ensure regressions never slip into production again.
Problem 1: The Premature Essay Scoring & False Failure Bug
The Flawed Logic
In Part 5: Building the Exam System, we designed the exam engine to support both multiple-choice and essay questions. Multiple-choice questions can be automatically graded immediately upon submission, whereas essay questions require manual review and score attribution by a teacher in the grading portal.
Here is how the original finishExam() method in app/Livewire/Student/ExamStart.php computed the final score:
PHP
// ❌ ORIGINAL VULNERABLE LOGIC
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: don't auto-grade, leave 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 includes essay points, but $earnedPoints does not!
$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,
]);
}
Why This Was Catastrophic
Consider an exam with:
- 5 multiple-choice questions (10 points each = 50 points)
- 2 essay questions (25 points each = 50 points)
- Total Possible Points: 100 points
- Passing Threshold: 75 points
A diligent student answers all 5 multiple-choice questions correctly (earning 50 points) and writes thorough essay answers. When they clicked "Selesaikan Ujian":
$earnedPointswas50.$totalPointswas100.$scorewas calculated asround((50 / 100) * 100) = 50.$is_passedwas evaluated as50 >= 75➔false(Failed!).- Even worse, the database enum for
statusin theexam_attemptstable only permitted['in_progress', 'completed', 'force_finished']in the original migration, so assigning'needs_grading'triggered a database exception on strict SQL modes!
The Solution in PR #17
-
Database Migration Update: We updated the
exam_attemptsmigration enum to officially supportneeds_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'); -
Decoupled Grading Lifecycle: When an exam contains essay questions, the score calculation is deferred entirely:
PHP
// ✅ REFACTORED SECURE LOGIC in ExamStart.php if ($hasEssay) { $status = 'needs_grading'; $score = null; // No premature numeric score! $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, ]); -
Guaranteed Answer Persistence with
updateOrCreate: Instead of relying on fragile previous queries, every answer (both essay text and multiple-choice selections) is upserted with timestamp tracking: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, // Pending teacher evaluation ] );
Problem 2: Missing Exam Access & Attempt Validation Guards
The Gap
Originally, ExamStart.php assumed that if a user reached the URL /student/exams/{examination}, they were allowed to start. There were no defensive checks in mount() or startExam().
A student could theoretically:
- Access exams designated for other classes (
classroom_id). - Access drafts or closed exams (
status !== 'published'). - Start an exam before
start_ator afterend_at. - Re-take an exam indefinitely even when
allow_retry === false. - Refresh the page and spawn duplicate attempt records in the database.
The Defensive Guard Clauses
In PR #17, we added comprehensive authorization and lifecycle guards in both mount() and startExam():
PHP
public function startExam(): void
{
$student = auth()->user()?->student;
// 1. Guard Classroom Authorization
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 Publication Status
if ($this->examination->status !== 'published') {
session()->flash('error', 'Ujian belum dipublikasikan atau sudah ditutup.');
$this->redirect(route('student.exams'), navigate: true);
return;
}
// 3. Guard Schedule Window
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. Resume Existing In-Progress Attempt (Prevents Duplication)
$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 Retry Allowance
$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. Safe Attempt Creation
$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();
}
Handling Elapsed Time on Resume
What if a student leaves the tab, turns off their laptop, and returns after their 60-minute duration has expired?
In mount(), we now compare the remaining time against the strictest ceiling between duration_minutes and the exam's overall end_at:
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;
}
If the deadline has passed, the attempt is immediately auto-completed and the student is gracefully redirected with their final submission preserved.
Problem 3: Attempt-Aware Dynamic UI States
On the student dashboard and exams listing, cards previously displayed a static "Mulai Ujian" button as long as the exam was published and unexpired. This led to student confusion: "Did my exam submit? Can I continue? Why does it say Start Exam again?"
Solution in Dashboard.php & Exams.php
We updated query eager-loading to fetch the student's latest attempt with each examination:
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();
In the Blade views (resources/views/livewire/student/dashboard.blade.php), we replaced static buttons with intelligent stateful badges:
| Attempt State | UI Action / Badge | Button Style |
|---|---|---|
status === 'in_progress' | Lanjutkan Ujian → | Amber solid button |
Finished & allow_retry == true | Ulangi Ujian → | Blue outline button |
Finished & status === 'needs_grading' | ⏳ Menunggu Penilaian | Purple pill badge |
Finished & status === 'completed' | ✓ Selesai (Score shown) | Green pill badge |
| Not started & Within schedule | Mulai Ujian → | Primary blue solid button |
Not started & start_at > now() | Belum Dimulai | Gray subtle badge |
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
Problem 4: Hardening with GitHub Actions CI & Security Pipeline
A critical takeaway from Issue #16 was that manual testing across dozens of UI states and role permutations is error-prone. To ensure high code quality moving forward, PR #17 established an end-to-end GitHub Actions workflow in .github/workflows/ci.yml.
The pipeline executes on every push to main, every Pull Request, and automatically every Monday at 02:00 UTC.
Highlights of the Pipeline
-
Deterministic Dependency Caching: Composer cache paths are resolved dynamically using
composer config cache-files-dirto cut CI run times from ~3 minutes to under 40 seconds. -
Automated Vulnerability Audits: Both backend (
composer audit --locked) and frontend (npm audit --audit-level=high) dependencies are scanned against known CVE databases. -
Committed Secret & Environment File Protection: A dedicated shell script scans git tracked files to ensure sensitive environment files or cryptographic private keys are never committed:
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 "❌ Security issue: Sensitive file(s) found in repository!" exit 1 fi -
Automated Code Style Enforcement: Laravel Pint runs with
--testflag to guarantee PSR-12 and Laravel standard code aesthetics across all contributions.
Open Source Maintainer Lessons
Handling Issue #16 reinforced several best practices for managing open-source software:
- Acknowledge and Validate Promptly: When a user files an issue with clear technical reproduction steps, acknowledge the issue early. Let them know you understand the gravity of the bug and outline the expected path to resolution.
- Treat Bug Reports as Systemic Clues:
Issue #16 initially focused on
ExamStart.php. But tracing the issue revealed that the student dashboard, exams list, database schema, and teacher monitor all touched the same attempt state machine. Fixing onlyExamStart.phpwould have left broken UI states elsewhere. - Automate Quality Gates (CI): Every bug you fix should inspire a test or a CI workflow step. If an issue was caused by an untested permutation, add test coverage so that it can never quietly regress in a future PR.
Summary of Changes
| Component | Before PR #17 | After PR #17 |
|---|---|---|
| Essay Grading | Essay points included in denominator; premature score calculated | Status set to needs_grading; score left as null pending teacher review |
| Database Schema | Status enum: ['in_progress', 'completed', 'force_finished'] | Added 'needs_grading' to status enum |
| Exam Access Guard | No classroom, schedule, or retry checks | Strict validation in mount() and startExam(); auto-finish on expired resume |
| Student UI | Static "Mulai Ujian" button | Dynamic badges: Lanjutkan, Ulangi, ⏳ Menunggu Penilaian, ✓ Selesai |
| Teacher Monitor | Crashed with Undefined array key "perlu_dinilai" on sort | Added sorting key with fallback ?? 99 |
| CI / CD Pipeline | None | Full GitHub Actions pipeline: Tests, Vite build, Pint, Composer/NPM audit, and secret scanner |
Special thanks to @ze0expl01t for filing Issue #16 and helping make the platform safer and more resilient for schools and students!
Continue to Part 10: IDOR Security Hardening →
🔗 Resources:
