security fix and fix parent pages
API CI/CD / Validate (composer + pint) (push) Successful in 3m7s
API CI/CD / Test (PHPUnit) (push) Failing after 5m46s
API CI/CD / Build frontend assets (push) Successful in 1m2s
API CI/CD / Security audit (push) Failing after 49s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped

This commit is contained in:
root
2026-07-06 02:14:16 -04:00
parent 39228168c8
commit 90f9857b06
43 changed files with 4323 additions and 132 deletions
@@ -45,6 +45,138 @@ class ClassProgressControllerTest extends TestCase
$this->assertCount(2, $response->json('data.items'));
}
public function test_admin_grouped_index_accepts_school_year_id_and_includes_legacy_rows(): void
{
$admin = $this->createUser('admin@example.com');
$this->assignRole($admin->id, 'administrator');
$this->seedClassSection();
DB::table('classSection')->where('class_section_id', 101)->update([
'semester' => '',
'school_year' => null,
]);
$schoolYearId = DB::table('school_years')->insertGetId([
'name' => '2025-2026',
'start_date' => '2025-08-01',
'end_date' => '2026-07-31',
'status' => 'active',
'is_current' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
ClassProgressReport::factory()->create([
'teacher_id' => $admin->id,
'class_section_id' => 101,
'school_year' => null,
'semester' => null,
'week_start' => '2026-02-08',
'week_end' => '2026-02-14',
]);
Sanctum::actingAs($admin);
$response = $this->getJson('/api/v1/class-progress?school_year_id='.$schoolYearId.'&group_by_week=1');
$response->assertOk();
$response->assertJsonPath('status', true);
$this->assertCount(1, $response->json('data.items'));
$administratorAlias = $this->getJson('/api/v1/administrator/progress?school_year_id='.$schoolYearId.'&group_by_week=1');
$administratorAlias->assertOk();
$this->assertCount(1, $administratorAlias->json('data.items'));
$adminAlias = $this->getJson('/api/v1/admin/progress?school_year_id='.$schoolYearId.'&group_by_week=1');
$adminAlias->assertOk();
$this->assertCount(1, $adminAlias->json('data.items'));
}
public function test_parent_progress_returns_reports_for_enrolled_sections(): void
{
$parent = $this->createUser('parent@example.com');
$teacher = $this->createUser('teacher-for-parent-progress@example.com');
$this->assignRole($parent->id, 'parent');
$this->seedClassSection();
DB::table('classSection')->where('class_section_id', 101)->update([
'semester' => '',
'school_year' => null,
]);
DB::table('students')->insert([
'id' => 301,
'school_id' => '301',
'firstname' => 'Student',
'lastname' => 'One',
'is_active' => 1,
'is_new' => 0,
'parent_id' => $parent->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
]);
DB::table('enrollments')->insert([
'student_id' => 301,
'class_section_id' => 101,
'parent_id' => $parent->id,
'enrollment_status' => 'enrolled',
'is_withdrawn' => 0,
'admission_status' => 'accepted',
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
$report = ClassProgressReport::factory()->create([
'teacher_id' => $teacher->id,
'class_section_id' => 101,
'school_year' => null,
'semester' => null,
'week_start' => '2026-02-08',
'week_end' => '2026-02-14',
]);
ClassProgressAttachment::factory()->create([
'report_id' => $report->id,
'file_path' => 'storage/class_material/sample.pdf',
'original_name' => 'sample.pdf',
]);
Sanctum::actingAs($parent);
$response = $this->getJson('/api/v1/parents/progress?school_year=2025-2026');
$response->assertOk();
$response->assertJsonPath('ok', true);
$this->assertCount(1, $response->json('data.items'));
$this->assertCount(1, $response->json('data.students'));
$this->assertSame($report->id, $response->json('data.items.0.id'));
$this->assertSame($report->id, $response->json('data.items.0.report_id'));
$this->assertNotEmpty($response->json('data.items.0.view_url'));
$this->assertNotEmpty($response->json('data.items.0.reports.Islamic Studies.attachments.0.download_url'));
$this->assertSame($report->id, $response->json('data.items.0.weekly_reports.0.id'));
$this->assertNotEmpty($response->json('data.items.0.weekly_reports.0.attachments.0.download_url'));
$sharedResponse = $this->getJson('/api/v1/class-progress?school_year=2025-2026&group_by_week=1');
$sharedResponse->assertOk();
$sharedResponse->assertJsonPath('status', true);
$this->assertCount(1, $sharedResponse->json('data.items'));
$this->assertSame($report->id, $sharedResponse->json('data.items.0.id'));
$this->assertSame($report->id, $sharedResponse->json('data.items.0.weekly_reports.0.id'));
$detailResponse = $this->getJson('/api/v1/class-progress/'.$report->id);
$detailResponse->assertOk();
$detailResponse->assertJsonPath('status', true);
$this->assertSame($report->id, $detailResponse->json('data.report.id'));
$this->assertNotEmpty($detailResponse->json('data.weekly_reports.0.attachments.0.download_url'));
$this->assertSame($report->id, $detailResponse->json('data.group.weekly_reports.0.id'));
$parentDetailResponse = $this->getJson('/api/v1/parents/progress/'.$report->id);
$parentDetailResponse->assertOk();
$parentDetailResponse->assertJsonPath('ok', true);
$parentDetailResponse->assertJsonPath('status', true);
$this->assertSame($report->id, $parentDetailResponse->json('data.report.id'));
$this->assertNotEmpty($parentDetailResponse->json('data.report.attachments.0.download_url'));
$this->assertSame($report->id, $parentDetailResponse->json('data.group.weekly_reports.0.id'));
}
public function test_store_creates_reports(): void
{
Storage::fake('public');
@@ -243,4 +375,24 @@ class ClassProgressControllerTest extends TestCase
return User::query()->where('email', $email)->firstOrFail();
}
private function assignRole(int $userId, string $roleName): void
{
$roleId = DB::table('roles')->insertGetId([
'name' => $roleName,
'slug' => $roleName,
'dashboard_route' => $roleName === 'parent' ? 'parent/dashboard' : 'administrator/administratordashboard',
'priority' => 100,
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('user_roles')->insert([
'user_id' => $userId,
'role_id' => $roleId,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -32,15 +32,28 @@ class ParentControllerTest extends TestCase
]);
DB::table('attendance_data')->insert([
'class_id' => 1,
'class_section_id' => 1,
'student_id' => $studentId,
'school_id' => '1',
'date' => '2025-10-01',
'status' => 'absent',
'reason' => 'Sick',
'semester' => 'Fall',
'school_year' => '2025-2026',
[
'class_id' => 1,
'class_section_id' => 1,
'student_id' => $studentId,
'school_id' => '1',
'date' => '2025-10-02',
'status' => 'present',
'reason' => null,
'semester' => 'Fall',
'school_year' => '2025-2026',
],
[
'class_id' => 1,
'class_section_id' => 1,
'student_id' => $studentId,
'school_id' => '1',
'date' => '2025-10-01',
'status' => 'absent',
'reason' => 'Sick',
'semester' => 'Fall',
'school_year' => '2025-2026',
],
]);
Sanctum::actingAs($parent);
@@ -48,6 +61,7 @@ class ParentControllerTest extends TestCase
$response->assertOk();
$response->assertJson(['ok' => true]);
$this->assertCount(1, $response->json('attendance'));
$response->assertJsonPath('attendance.0.status', 'absent');
}
@@ -91,6 +105,14 @@ class ParentControllerTest extends TestCase
private function seedParent(): User
{
$roleId = DB::table('roles')->insertGetId([
'name' => 'parent',
'slug' => 'parent',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$id = DB::table('users')->insertGetId([
'firstname' => 'Parent',
'lastname' => 'User',
@@ -106,6 +128,15 @@ class ParentControllerTest extends TestCase
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
'is_verified' => 1,
'is_suspended' => 0,
]);
DB::table('user_roles')->insert([
'user_id' => $id,
'role_id' => $roleId,
'created_at' => now(),
'updated_at' => now(),
]);
return User::query()->findOrFail($id);
@@ -30,6 +30,29 @@ class ReportCardsControllerTest extends TestCase
$this->assertArrayHasKey('class_section_id', $response->json('data.classSections.0'));
}
public function test_parent_meta_is_scoped_to_only_their_students(): void
{
$this->seedConfig();
$admin = $this->seedUser();
$parent = $this->seedParentUser(10);
$data = $this->seedReportCardData($admin->id);
$other = $this->seedOtherScoredStudent($admin->id);
Sanctum::actingAs($parent);
$response = $this->getJson('/api/v1/reports/report-cards/meta?school_year=2025-2026&semester=Fall');
$response->assertOk();
$response->assertJsonPath('status', true);
$this->assertSame([$data['student_id']], collect($response->json('data.students'))->pluck('id')->all());
$this->getJson('/api/v1/reports/report-cards/students/'.$other['student_id'].'?school_year=2025-2026&semester=Fall')
->assertForbidden();
$this->getJson('/api/v1/reports/report-cards/classes/'.$data['class_section_id'].'?school_year=2025-2026&semester=Fall')
->assertForbidden();
}
public function test_completeness_returns_summary_and_students(): void
{
$this->seedConfig();
@@ -155,6 +178,48 @@ class ReportCardsControllerTest extends TestCase
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
'is_verified' => 1,
'is_suspended' => 0,
]);
return User::query()->findOrFail($userId);
}
private function seedParentUser(int $userId): User
{
$roleId = DB::table('roles')->insertGetId([
'name' => 'parent',
'slug' => 'parent',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('users')->insert([
'id' => $userId,
'firstname' => 'Parent',
'lastname' => 'User',
'cellphone' => '8888888888',
'email' => 'parent-reportcards@example.com',
'address_street' => '123 Parent',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('password'),
'user_type' => 'primary',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
'is_verified' => 1,
'is_suspended' => 0,
]);
DB::table('user_roles')->insert([
'user_id' => $userId,
'role_id' => $roleId,
'created_at' => now(),
'updated_at' => now(),
]);
return User::query()->findOrFail($userId);
@@ -280,6 +345,59 @@ class ReportCardsControllerTest extends TestCase
];
}
private function seedOtherScoredStudent(int $userId): array
{
DB::table('students')->insert([
'id' => 102,
'school_id' => 'SCH3',
'firstname' => 'Other',
'lastname' => 'Student',
'age' => 10,
'gender' => 'Female',
'photo_consent' => 1,
'parent_id' => 11,
'year_of_registration' => '2025-2026',
'semester' => 'Fall',
'school_year' => '2025-2026',
'is_active' => 1,
]);
DB::table('student_class')->insert([
'student_id' => 102,
'class_section_id' => 200,
'school_year' => '2025-2026',
'semester' => 'Fall',
'is_event_only' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('semester_scores')->insert([
'student_id' => 102,
'school_id' => 'SCH3',
'class_section_id' => 200,
'updated_by' => $userId,
'homework_avg' => 87,
'quiz_avg' => 86,
'project_avg' => 85,
'test_avg' => 84,
'midterm_exam_score' => 83,
'final_exam_score' => 82,
'attendance_score' => 81,
'ptap_score' => 80,
'semester_score' => 84,
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
return [
'student_id' => 102,
'class_section_id' => 200,
];
}
private function seedAcknowledgement(int $studentId): void
{
DB::table('report_card_acknowledgements')->insert([
@@ -135,8 +135,15 @@ class SchoolYearControllerTest extends TestCase
$missing = [];
foreach ($routes as $route) {
$uri = $route->uri();
$middleware = $route->gatherMiddleware();
$hasPermission = collect($middleware)->contains(fn (string $middleware): bool => str_starts_with($middleware, 'perm:'));
$isContextRoute = in_array($uri, [
'api/v1/school-years',
'api/v1/school-years/current',
'api/v1/school-years/options',
], true);
$hasPermission = $isContextRoute
|| collect($middleware)->contains(fn (string $middleware): bool => str_starts_with($middleware, 'perm:'));
$hasAuth = collect($middleware)->contains(fn (string $middleware): bool => str_contains($middleware, 'multi.auth') || str_contains($middleware, 'auth'));
$hasAccountGuard = in_array('account.active', $middleware, true);
@@ -148,6 +155,25 @@ class SchoolYearControllerTest extends TestCase
$this->assertSame([], $missing, 'School-year routes missing auth/account/permission middleware: '.implode(', ', $missing));
}
public function test_active_user_can_load_school_year_context_list_without_school_year_permission(): void
{
$this->seedClosureData();
$this->actingAs(User::query()->findOrFail(10), 'api');
$response = $this->getJson('/api/v1/school-years');
$response->assertOk()
->assertJsonPath('ok', true)
->assertJsonPath('current_school_year', '2025-2026');
$this->postJson('/api/v1/school-years', [
'name' => '2026-2027',
'start_date' => '2026-09-01',
'end_date' => '2027-06-30',
])->assertForbidden();
}
public function test_admin_can_create_draft_school_year_via_api(): void
{
$this->seedClosureData();
@@ -426,12 +452,15 @@ class SchoolYearControllerTest extends TestCase
];
foreach ($permissions as $permission) {
$permissionId = DB::table('permissions')->insertGetId([
'name' => $permission,
'description' => 'Test permission '.$permission,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('permissions')->updateOrInsert(
['name' => $permission],
[
'description' => 'Test permission '.$permission,
'created_at' => now(),
'updated_at' => now(),
]
);
$permissionId = (int) DB::table('permissions')->where('name', $permission)->value('id');
DB::table('role_permissions')->insert([
'role_id' => 1,
'permission_id' => $permissionId,
@@ -3,7 +3,9 @@
namespace Tests\Feature\Api\V1\Settings;
use App\Models\User;
use App\Services\Settings\SchoolCalendar\SchoolCalendarContextService;
use App\Services\Settings\SchoolCalendar\SchoolCalendarMutationService;
use App\Services\Settings\SchoolCalendar\SchoolCalendarNotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
@@ -44,6 +46,40 @@ class SchoolCalendarControllerTest extends TestCase
$this->assertArrayHasKey('extendedProps', $response->json('data.events.0'));
}
public function test_parent_can_list_only_parent_audience_events(): void
{
$this->seedConfig();
$parent = $this->seedParentUser();
$parentEventId = $this->seedEvent();
$teacherEventId = DB::table('calendar_events')->insertGetId([
'title' => 'Teacher Only',
'date' => '2026-01-03',
'description' => 'Hidden from parents',
'notify_parent' => 0,
'notify_admin' => 0,
'notify_teacher' => 1,
'no_school' => 0,
'semester' => 'Fall',
'school_year' => '2025-2026',
'notification_sent' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
Sanctum::actingAs($parent);
$response = $this->getJson('/api/v1/settings/school-calendar/events?audience=parent&school_year=2025-2026&include_meetings=0');
$response->assertOk();
$response->assertJsonPath('status', true);
$ids = collect($response->json('data.events'))->pluck('id')->all();
$this->assertContains($parentEventId, $ids);
$this->assertNotContains($teacherEventId, $ids);
$this->getJson('/api/v1/settings/school-calendar/events?school_year=2025-2026')
->assertForbidden();
}
public function test_show_returns_event(): void
{
$this->seedConfig();
@@ -136,7 +172,10 @@ class SchoolCalendarControllerTest extends TestCase
Sanctum::actingAs($user);
$this->app->bind(SchoolCalendarMutationService::class, function () {
return new class
return new class(
app(SchoolCalendarContextService::class),
app(SchoolCalendarNotificationService::class),
) extends SchoolCalendarMutationService
{
public function create(array $payload, array $targets = []): array
{
@@ -173,6 +212,14 @@ class SchoolCalendarControllerTest extends TestCase
private function seedUser(): User
{
$roleId = DB::table('roles')->insertGetId([
'name' => 'administrator',
'slug' => 'administrator',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$userId = DB::table('users')->insertGetId([
'firstname' => 'Admin',
'lastname' => 'User',
@@ -188,6 +235,54 @@ class SchoolCalendarControllerTest extends TestCase
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
'is_verified' => 1,
'is_suspended' => 0,
]);
DB::table('user_roles')->insert([
'user_id' => $userId,
'role_id' => $roleId,
'created_at' => now(),
'updated_at' => now(),
]);
return User::query()->findOrFail($userId);
}
private function seedParentUser(): User
{
$roleId = DB::table('roles')->insertGetId([
'name' => 'parent',
'slug' => 'parent',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$userId = DB::table('users')->insertGetId([
'firstname' => 'Parent',
'lastname' => 'User',
'cellphone' => '8888888888',
'email' => 'calendar-parent@example.com',
'address_street' => '123 Street',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('password'),
'user_type' => 'primary',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
'is_verified' => 1,
'is_suspended' => 0,
]);
DB::table('user_roles')->insert([
'user_id' => $userId,
'role_id' => $roleId,
'created_at' => now(),
'updated_at' => now(),
]);
return User::query()->findOrFail($userId);
@@ -74,6 +74,8 @@ class WhatsappInviteControllerTest extends TestCase
public function test_send_invites_returns_error_when_no_listener(): void
{
Event::forget(WhatsappInvitesSend::class);
$user = $this->createUser();
Sanctum::actingAs($user);
@@ -6,6 +6,7 @@ use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class RequirePermissionMiddlewareTest extends TestCase
@@ -27,6 +28,8 @@ class RequirePermissionMiddlewareTest extends TestCase
'zip' => '12345',
'accept_school_policy' => 1,
'status' => 'Active',
'is_verified' => 1,
'is_suspended' => 0,
'password' => 'hashed',
'semester' => 'Fall',
]);
@@ -59,6 +62,8 @@ class RequirePermissionMiddlewareTest extends TestCase
'zip' => '12345',
'accept_school_policy' => 1,
'status' => 'Active',
'is_verified' => 1,
'is_suspended' => 0,
'password' => 'hashed',
'semester' => 'Fall',
]);
@@ -81,4 +86,77 @@ class RequirePermissionMiddlewareTest extends TestCase
$response->assertOk();
$response->assertSee('ok');
}
public function test_multi_auth_blocks_suspended_sanctum_user(): void
{
Route::middleware('multi.auth')->get('/test-active-account', fn () => 'ok');
$user = $this->createUser([
'email' => 'suspended@example.com',
'is_verified' => 1,
'is_suspended' => 1,
]);
Sanctum::actingAs($user);
$this->get('/test-active-account', ['Accept' => 'application/json'])
->assertStatus(403)
->assertJsonPath('message', 'Account unavailable.');
}
public function test_permission_middleware_blocks_unverified_user_even_with_permission(): void
{
$permissionId = DB::table('permissions')->insertGetId([
'name' => 'edit_students',
'description' => 'Edit students',
]);
$roleId = DB::table('roles')->insertGetId([
'name' => 'admin',
'priority' => 1,
]);
$user = $this->createUser([
'email' => 'unverified-perm@example.com',
'is_verified' => 0,
'is_suspended' => 0,
]);
DB::table('user_roles')->insert([
'user_id' => $user->id,
'role_id' => $roleId,
]);
DB::table('role_permissions')->insert([
'role_id' => $roleId,
'permission_id' => $permissionId,
'can_read' => 1,
]);
Route::middleware('perm:edit_students')->get('/test-perm-unverified', fn () => 'ok');
$this->actingAs($user)->get('/test-perm-unverified', ['Accept' => 'application/json'])
->assertStatus(403)
->assertJsonPath('message', 'Account unavailable.');
}
private function createUser(array $overrides = []): User
{
return User::query()->create(array_merge([
'firstname' => 'Test',
'lastname' => 'User',
'email' => 'test-user@example.com',
'cellphone' => '5555555555',
'address_street' => '123 Main',
'city' => 'City',
'state' => 'CT',
'zip' => '12345',
'accept_school_policy' => 1,
'status' => 'Active',
'is_verified' => 1,
'is_suspended' => 0,
'password' => 'hashed',
'semester' => 'Fall',
], $overrides));
}
}
@@ -52,15 +52,28 @@ class ParentAttendanceServiceTest extends TestCase
]);
DB::table('attendance_data')->insert([
'class_id' => 1,
'class_section_id' => 1,
'student_id' => $studentId,
'school_id' => '1',
'date' => '2025-10-02',
'status' => 'late',
'reason' => 'Traffic',
'semester' => 'Fall',
'school_year' => '2025-2026',
[
'class_id' => 1,
'class_section_id' => 1,
'student_id' => $studentId,
'school_id' => '1',
'date' => '2025-10-02',
'status' => 'late',
'reason' => 'Traffic',
'semester' => 'Fall',
'school_year' => '2025-2026',
],
[
'class_id' => 1,
'class_section_id' => 1,
'student_id' => $studentId,
'school_id' => '1',
'date' => '2025-10-01',
'status' => 'present',
'reason' => null,
'semester' => 'Fall',
'school_year' => '2025-2026',
],
]);
$service = new ParentAttendanceService(new ParentConfigService);