seedOtherFamily(); foreach ($this->otherFamilyPaths($other['student_id'], $other['parent_id']) as $path) { $response = $this->requestAs($this->parent, 'GET', $path); $this->assertStatusIn($response, [200, 302, 401, 403, 404, 405, 419, 422], "parent privacy $path"); $this->assertNoServerError($response, "parent privacy $path"); if ($response->getStatusCode() === 200 && $this->isJsonString($response->getContent())) { $body = json_encode($response->json(), JSON_THROW_ON_ERROR); $this->assertStringNotContainsString((string) $other['student_id'], $body, "$path exposed another family student id."); $this->assertStringNotContainsString($other['email'], $body, "$path exposed another family email."); } } } public function test_teacher_class_endpoints_do_not_expose_unassigned_class_records(): void { $other = $this->seedTeacherClassWithStudent(9802); $unassignedTeacher = $this->createApiUserWithRole('teacher'); foreach ($this->teacherClassPaths((int) $other['class_section_id'], (int) $other['student_id']) as $path) { $response = $this->requestAs($unassignedTeacher, 'GET', $path); $this->assertStatusIn($response, [200, 302, 401, 403, 404, 405, 419, 422], "teacher class isolation $path"); $this->assertNoServerError($response, "teacher class isolation $path"); if ($response->getStatusCode() === 200 && $this->isJsonString($response->getContent())) { $body = json_encode($response->json(), JSON_THROW_ON_ERROR); $this->assertStringNotContainsString((string) $other['student_id'], $body, "$path exposed unassigned student id."); } } } public function test_serialized_users_do_not_leak_sensitive_auth_material(): void { foreach ($this->identityAndDirectoryPaths() as $path) { $response = $this->requestAs($this->admin, 'GET', $path); $this->assertStatusIn($response, [200, 204, 302, 401, 403, 404, 405, 419, 422], "sensitive serialization $path"); $this->assertNoServerError($response, "sensitive serialization $path"); if ($response->getStatusCode() === 200) { $content = strtolower($response->getContent()); $this->assertStringNotContainsString('password_hash', $content, "$path leaked password_hash."); $this->assertStringNotContainsString('remember_token', $content, "$path leaked remember_token."); $this->assertStringNotContainsString('api_token', $content, "$path leaked api_token."); $this->assertStringNotContainsString('two_factor_secret', $content, "$path leaked two_factor_secret."); } } } /** @return array{parent_id:int, student_id:int, email:string} */ private function seedOtherFamily(): array { $otherParent = $this->createApiUserWithRole('parent', [ 'email' => 'other.family.' . uniqid() . '@example.test', 'firstname' => 'Other', 'lastname' => 'Family', ]); $studentId = DB::table('students')->insertGetId([ 'school_id' => 'S-OTHER-' . random_int(1000, 9999), 'firstname' => 'Other', 'lastname' => 'Student', 'dob' => '2016-01-01', 'age' => 9, 'gender' => 'Female', 'parent_id' => $otherParent->id, 'year_of_registration' => '2025', 'school_year' => self::E2E_SCHOOL_YEAR, 'semester' => self::E2E_SEMESTER, 'is_active' => 1, ]); return ['parent_id' => $otherParent->id, 'student_id' => $studentId, 'email' => $otherParent->email]; } /** @return list */ private function otherFamilyPaths(int $studentId, int $parentId): array { return [ "api/v1/parents/students/$studentId", "api/v1/parents/students/$studentId/attendance", "api/v1/parents/students/$studentId/invoices", "api/v1/parents/$parentId/profile", "api/v1/families/$parentId", "api/v1/emergency-contacts/student/$studentId", ]; } /** @return list */ private function teacherClassPaths(int $classSectionId, int $studentId): array { return [ "api/v1/teacher/classes/$classSectionId/roster", "api/v1/teacher/classes/$classSectionId/progress", "api/v1/teacher/students/$studentId/scores", "api/v1/teacher/classes/$classSectionId/attendance", "api/v1/class-progress/$classSectionId", ]; } /** @return list */ private function identityAndDirectoryPaths(): array { return [ 'api/v1/auth/me', 'api/v1/users', 'api/v1/parents/profile', 'api/v1/teachers/classes', 'api/v1/students', ]; } }