Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
->name('filament-lms::certificates.download')
->middleware('auth');
Route::get('lms/certificates/{course}/{user}', [CertificateController::class, 'show'])
->name('filament-lms::certificates.show');
->name('filament-lms::certificates.show')
->middleware('auth');

Route::get('lms/scorm-package/{document}/{entry?}', [ScormPackageController::class, 'show'])
->where('entry', '.*')
Expand Down
5 changes: 2 additions & 3 deletions src/Http/Controllers/CertificateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@ public function show($courseId, $userId): View
throw new InvalidArgumentException('User model must implement Authenticatable contract');
}

if (! request()->hasValidSignature() &&
! $course->completedByUserAt($userId) &&
! Auth::user()?->can('update', $course)) {
// HTML certificate is private: owner or course updater only (no guest / completion-only access).
if (Auth::id() != $userId && ! Auth::user()?->can('update', $course)) {
abort(403);
}

Expand Down
121 changes: 121 additions & 0 deletions tests/Feature/CertificateControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

declare(strict_types=1);

use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
use Tapp\FilamentCertificateBuilder\Actions\ResolveSampleCertificateTokensAction;
use Tapp\FilamentLms\Models\Course;
use Tapp\FilamentLms\Support\CertificateBuilder;
use Tapp\FilamentLms\Tests\TestUser;
Expand All @@ -10,6 +13,29 @@
config([
'filament-lms.user_model' => TestUser::class,
'auth.providers.users.model' => TestUser::class,
'certificate-builder.token_sets.course' => [
'label' => 'Course',
'resolver' => ResolveSampleCertificateTokensAction::class,
'tokens' => [
'recipient_name' => [
'label' => 'Recipient name',
'sample' => 'Jane Doe',
],
'course_name' => [
'label' => 'Course name',
'sample' => 'Sample Course',
],
'date_range' => [
'label' => 'Date range',
'sample' => 'January 15th - March 15th 2026',
],
],
'default_copy' => [
'certifying_line' => 'This certifies that',
'completed_line' => 'has successfully completed',
'description' => '',
],
],
]);
});

Expand All @@ -31,6 +57,93 @@
->assertNotFound();
});

it('redirects guests away from the certificate show page', function () {
Route::get('/login', fn () => 'login')->name('login');

$user = TestUser::query()->create([
'name' => 'Guest Target',
'email' => 'guest-target-cert@example.com',
'password' => bcrypt('password'),
]);

$course = Course::factory()->create([
'name' => 'Guest Blocked Course',
]);

$course->users()->attach($user->id, ['completed_at' => now()]);

$this->get(route('filament-lms::certificates.show', ['course' => $course->id, 'user' => $user->id]))
->assertRedirect(route('login'));
});

it('allows the certificate owner to view the HTML certificate', function () {
$user = TestUser::query()->create([
'name' => 'Owner Learner',
'email' => 'owner-cert@example.com',
'password' => bcrypt('password'),
]);

$course = Course::factory()->create([
'name' => 'Owner Course',
]);

$course->users()->attach($user->id, ['completed_at' => now()]);

$this->actingAs($user)
->get(route('filament-lms::certificates.show', ['course' => $course->id, 'user' => $user->id]))
->assertOk();
});

it('allows an admin who can update the course to preview another users certificate', function () {
Gate::policy(Course::class, CertificateShowCourseUpdatePolicy::class);

$learner = TestUser::query()->create([
'name' => 'Learner',
'email' => 'learner-cert@example.com',
'password' => bcrypt('password'),
]);
$admin = TestUser::query()->create([
'name' => 'Allowed Admin',
'email' => 'allowed@example.com',
'password' => bcrypt('password'),
]);

$course = Course::factory()->create([
'name' => 'Admin Preview Course',
]);

$course->users()->attach($learner->id, ['completed_at' => now()]);

$this->actingAs($admin)
->get(route('filament-lms::certificates.show', ['course' => $course->id, 'user' => $learner->id]))
->assertOk();
});

it('forbids another authenticated user from viewing someone elses certificate', function () {
Gate::policy(Course::class, CertificateShowCourseUpdatePolicy::class);

$owner = TestUser::query()->create([
'name' => 'Certificate Owner',
'email' => 'owner-other-cert@example.com',
'password' => bcrypt('password'),
]);
$stranger = TestUser::query()->create([
'name' => 'Stranger',
'email' => 'stranger-cert@example.com',
'password' => bcrypt('password'),
]);

$course = Course::factory()->create([
'name' => 'Private Certificate Course',
]);

$course->users()->attach($owner->id, ['completed_at' => now()]);

$this->actingAs($stranger)
->get(route('filament-lms::certificates.show', ['course' => $course->id, 'user' => $owner->id]))
->assertForbidden();
});

it('forbids download when the user has not completed the course', function () {
$user = TestUser::query()->create([
'name' => 'Pat Learner',
Expand All @@ -57,3 +170,11 @@
->and(CertificateBuilder::canCreateTemplate($user, $course))->toBeTrue()
->and(CertificateBuilder::canEditTemplate($user, $course))->toBeFalse();
});

class CertificateShowCourseUpdatePolicy
{
public function update(object $user, Course $course): bool
{
return $user->email === 'allowed@example.com';
}
}
Loading