add test batches

This commit is contained in:
root
2026-06-08 23:45:55 -04:00
parent c792b8be05
commit 8d4d610b82
1480 changed files with 22587 additions and 10762 deletions
@@ -0,0 +1,173 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Classroom, attendance, grading, and report-card full-surface probes.
*/
class ApiAcademicAttendanceFullSurfaceContractTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
use AssertsE2EApiResponses;
public function test_academic_attendance_and_grading_surfaces_have_controlled_contracts(): void
{
$this->seedE2EConfiguration();
$world = $this->seedTeacherClassWithStudent(9301);
$this->actingAs($world['teacher'], 'api');
foreach ($this->teacherGetEndpoints($world) as $uri) {
$response = $this->getJson($uri);
$this->assertNoServerError($response, 'Teacher GET ' . $uri);
$this->assertStatusIn($response, [200, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], 'Teacher GET ' . $uri);
}
foreach ($this->teacherMutationEndpoints($world) as [$method, $uri, $payload]) {
$response = $this->json($method, $uri, $payload);
$this->assertNoServerError($response, 'Teacher ' . $method . ' ' . $uri);
$this->assertStatusIn($response, [200, 201, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], 'Teacher ' . $method . ' ' . $uri);
}
$this->actingAsApiAdministrator();
foreach ($this->adminAcademicEndpoints($world) as [$method, $uri, $payload]) {
$response = $this->json($method, $uri, $payload);
$this->assertNoServerError($response, 'Admin academic ' . $method . ' ' . $uri);
$this->assertStatusIn($response, [200, 201, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], 'Admin academic ' . $method . ' ' . $uri);
}
}
/** @param array<string, mixed> $world @return list<string> */
private function teacherGetEndpoints(array $world): array
{
$classSectionId = (int) $world['class_section_id'];
$studentId = (int) $world['student_id'];
return [
'/api/v1/teachers/classes?class_section_id=' . $classSectionId,
'/api/v1/teacher/class-view?class_section_id=' . $classSectionId,
'/api/v1/teacher/no-classes',
'/api/v1/teacher/select-semester',
'/api/v1/teacher/homework-list?class_section_id=' . $classSectionId,
'/api/v1/teacher/add-homework?class_section_id=' . $classSectionId,
'/api/v1/teacher/add-quiz?class_section_id=' . $classSectionId,
'/api/v1/teacher/add-quiz-column-form?class_section_id=' . $classSectionId,
'/api/v1/teacher/add-midterm-exam?class_section_id=' . $classSectionId,
'/api/v1/teacher/add-final-exam?class_section_id=' . $classSectionId,
'/api/v1/teacher/add-participation?class_section_id=' . $classSectionId,
'/api/v1/teacher/add-project?class_section_id=' . $classSectionId,
'/api/v1/teacher/teacher-assignment',
'/api/v1/teacher/exam-drafts',
'/api/v1/teacher/drafts',
'/api/v1/teacher/inventory/books/distribute',
'/api/v1/teacher/print-requests/context',
'/api/v1/teacher/absence-vacation',
'/api/v1/teacher/showupdate-attendance?class_section_id=' . $classSectionId,
'/api/v1/teacher/showupdate_attendance?class_section_id=' . $classSectionId,
'/api/v1/teacher/scores?class_section_id=' . $classSectionId,
'/api/v1/teacher/calendar',
'/api/v1/teacher/class-progress-submit?class_section_id=' . $classSectionId,
'/api/v1/teacher/class-progress-history?class_section_id=' . $classSectionId,
'/api/v1/teacher/class-progress-view?class_section_id=' . $classSectionId,
'/api/v1/teacher/competition-scores',
'/api/v1/attendance/teacher/grid?class_section_id=' . $classSectionId,
'/api/v1/attendance/teacher/form?class_section_id=' . $classSectionId,
'/api/v1/attendance/admin/daily?date=2025-10-05&class_section_id=' . $classSectionId,
'/api/v1/attendance/staff/month?month=2025-10',
'/api/v1/attendance/staff/admins',
'/api/v1/attendance/late-slip-logs',
'/api/v1/attendance/late-slip-logs/1',
'/api/v1/attendance/early-dismissals',
'/api/v1/attendance/early-dismissals/student-options',
'/api/v1/attendance/management/dashboard',
'/api/v1/attendance-tracking/pending-violations',
'/api/v1/attendance-tracking/notified-violations',
'/api/v1/attendance-tracking/student-case/' . $studentId,
'/api/v1/attendance-tracking/compose',
'/api/v1/attendance-tracking/parents-info',
'/api/v1/attendance-comment-templates',
'/api/v1/attendance-comment-templates/list-data',
'/api/v1/attendance-comment-templates/1',
'/api/v1/attendance-templates',
'/api/v1/assignments',
'/api/v1/assignments/class-assignment-data',
'/api/v1/scores/overview?class_section_id=' . $classSectionId,
'/api/v1/scores/homework?class_section_id=' . $classSectionId,
'/api/v1/scores/quiz?class_section_id=' . $classSectionId,
'/api/v1/scores/project?class_section_id=' . $classSectionId,
'/api/v1/scores/midterm?class_section_id=' . $classSectionId,
'/api/v1/scores/final?class_section_id=' . $classSectionId,
'/api/v1/scores/participation?class_section_id=' . $classSectionId,
'/api/v1/grading/homework-tracking?class_section_id=' . $classSectionId,
'/api/v1/reports/report-cards/meta?class_section_id=' . $classSectionId,
];
}
/** @param array<string, mixed> $world @return list<array{0: string, 1: string, 2: array<string, mixed>}> */
private function teacherMutationEndpoints(array $world): array
{
$studentId = (int) $world['student_id'];
$classSectionId = (int) $world['class_section_id'];
$scorePayload = ['class_section_id' => $classSectionId, 'student_id' => $studentId, 'score' => 95, 'points' => 10, 'column' => 'E2E Probe'];
return [
['POST', '/api/v1/attendance/teacher/submit', ['class_section_id' => $classSectionId, 'attendance' => [$studentId => 'present'], 'date' => '2025-10-05']],
['POST', '/api/v1/attendance/admin/update', ['student_id' => $studentId, 'status' => 'present', 'date' => '2025-10-05']],
['POST', '/api/v1/attendance/admin/add-entry', ['student_id' => $studentId, 'status' => 'present', 'date' => '2025-10-05']],
['POST', '/api/v1/attendance/staff/admins/save', ['attendance' => []]],
['POST', '/api/v1/attendance/staff/cell', ['user_id' => $world['teacher']->id, 'date' => '2025-10-05', 'status' => 'present']],
['DELETE', '/api/v1/attendance/late-slip-logs/1', []],
['POST', '/api/v1/attendance/early-dismissals', ['student_id' => $studentId, 'reason' => 'Contract probe', 'dismissal_time' => '12:00']],
['POST', '/api/v1/attendance/early-dismissals/signature', ['student_id' => $studentId, 'signature' => 'data:image/png;base64,ZmFrZQ==']],
['POST', '/api/v1/attendance/management/scan', ['badge_code' => 'INVALID-BADGE']],
['POST', '/api/v1/attendance/management/manual-entry', ['student_id' => $studentId, 'status' => 'present']],
['POST', '/api/v1/attendance/management/exit-entry', ['student_id' => $studentId]],
['POST', '/api/v1/attendance/management/absent', ['student_id' => $studentId]],
['POST', '/api/v1/attendance/management/1/follow-up', ['notes' => 'Contract probe']],
['POST', '/api/v1/attendance/management/1/late-slip-reprint', ['reason' => 'Contract probe']],
['POST', '/api/v1/attendance-tracking/record', ['student_id' => $studentId, 'violation_type' => 'late', 'date' => '2025-10-05']],
['POST', '/api/v1/attendance-tracking/send-auto-emails', ['student_ids' => [$studentId]]],
['POST', '/api/v1/attendance-tracking/send-manual-email', ['student_id' => $studentId, 'message' => 'Contract probe']],
['POST', '/api/v1/attendance-tracking/save-notification-note', ['student_id' => $studentId, 'note' => 'Contract probe']],
['POST', '/api/v1/attendance-comment-templates', ['name' => 'Probe', 'comment' => 'Contract probe']],
['PUT', '/api/v1/attendance-comment-templates/1', ['name' => 'Probe Updated', 'comment' => 'Contract probe updated']],
['DELETE', '/api/v1/attendance-comment-templates/1', []],
['POST', '/api/v1/attendance-templates/save', ['name' => 'Probe', 'comment' => 'Contract probe']],
['POST', '/api/v1/attendance-templates/delete', ['id' => 1]],
['POST', '/api/v1/assignments', ['class_section_id' => $classSectionId, 'title' => 'Contract Assignment']],
['POST', '/api/v1/teacher/add-homework', $scorePayload],
['POST', '/api/v1/teacher/add-quiz', $scorePayload],
['POST', '/api/v1/teacher/add-quiz-column-form', ['class_section_id' => $classSectionId, 'name' => 'Probe Quiz']],
['POST', '/api/v1/teacher/add-midterm-exam', $scorePayload],
['POST', '/api/v1/teacher/add-final-exam', $scorePayload],
['POST', '/api/v1/teacher/add-participation', $scorePayload],
['POST', '/api/v1/teacher/add-project', $scorePayload],
['POST', '/api/v1/teacher/exam-drafts', ['class_section_id' => $classSectionId, 'title' => 'Contract Draft', 'content' => 'Draft body']],
['POST', '/api/v1/teacher/drafts', ['class_section_id' => $classSectionId, 'title' => 'Contract Draft', 'content' => 'Draft body']],
['POST', '/api/v1/teacher/inventory/books/distribute', ['student_id' => $studentId, 'item_id' => 1, 'quantity' => 1]],
['POST', '/api/v1/teacher/absence-vacation', ['date' => '2025-10-05', 'reason' => 'Contract probe']],
['POST', '/api/v1/teacher/class-progress-submit', ['class_section_id' => $classSectionId, 'student_id' => $studentId, 'progress' => 'Good']],
];
}
/** @param array<string, mixed> $world @return list<array{0: string, 1: string, 2: array<string, mixed>}> */
private function adminAcademicEndpoints(array $world): array
{
return [
['GET', '/api/v1/reports/report-cards?student_id=' . $world['student_id'], []],
['GET', '/api/v1/reports/stickers?student_id=' . $world['student_id'], []],
['GET', '/api/v1/badges/form-options', []],
['POST', '/api/v1/print-requests', ['title' => 'Contract Probe', 'copies' => 1, 'needed_by' => '2025-10-12']],
['GET', '/api/v1/print-requests/admin', []],
['PATCH', '/api/v1/print-requests/1/status', ['status' => 'completed']],
['GET', '/api/v1/print-requests/teacher', []],
['PATCH', '/api/v1/print-requests/1', ['title' => 'Contract Probe Updated']],
['DELETE', '/api/v1/print-requests/1', []],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiAccountStatusLifecycleContractTest extends FullSurfaceE2EContractCase
{
public function test_suspended_disabled_and_pending_status_values_do_not_gain_access_through_profile_or_dashboard_routes(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['auth/me', 'frontend', 'dashboard', 'profile', 'preferences', 'notifications']), 0, 80);
$statuses = ['suspended', 'disabled', 'inactive', 'pending', 'blocked'];
foreach ($statuses as $status) {
$actor = $this->createApiUserWithRole('parent', ['status' => $status]);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if ($method !== 'GET') {
continue;
}
$uri = $route->uri();
$response = $this->requestAs($actor, 'GET', $uri);
$this->assertControlled($response, 'GET', $uri);
$this->assertNoServerError($response, 'account status ' . $status . ' at GET ' . $uri);
}
}
}
}
@@ -0,0 +1,163 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Administrator full-surface operational contract checks.
*/
class ApiAdminOperationsFullSurfaceContractTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
use AssertsE2EApiResponses;
public function test_administrator_operational_surface_responds_with_controlled_contracts(): void
{
$this->seedE2EConfiguration();
$world = $this->seedTeacherClassWithStudent(9201);
$admin = $this->actingAsApiAdministrator();
foreach ($this->adminGetEndpoints($world) as $uri) {
$response = $this->getJson($uri);
$this->assertNoServerError($response, 'Admin GET ' . $uri);
$this->assertStatusIn($response, [200, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], 'Admin GET ' . $uri);
}
foreach ($this->adminMutationEndpoints($world, $admin->id) as [$method, $uri, $payload]) {
$response = $this->json($method, $uri, $payload);
$this->assertNoServerError($response, 'Admin ' . $method . ' ' . $uri);
$this->assertStatusIn($response, [200, 201, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], 'Admin ' . $method . ' ' . $uri);
}
}
/**
* @param array<string, mixed> $world
* @return list<string>
*/
private function adminGetEndpoints(array $world): array
{
$studentId = (int) $world['student_id'];
$classSectionId = (int) $world['class_section_id'];
return [
'/api/v1/administrator/attendance-templates',
'/api/v1/administrator/absence',
'/api/v1/administrator/dashboard/metrics',
'/api/v1/administrator/dashboard/user-search?query=Fixture',
'/api/v1/administrator/teacher-submissions',
'/api/v1/administrator/trophy',
'/api/v1/administrator/trophy/winners',
'/api/v1/administrator/trophy/final',
'/api/v1/administrator/teacher-class/assignments',
'/api/v1/administrator/notifications/alerts',
'/api/v1/administrator/notifications/print-recipients',
'/api/v1/administrator/enrollment-withdrawal',
'/api/v1/administrator/enrollment-withdrawal/new-students',
'/api/v1/administrator/enroll-withdrawal',
'/api/v1/administrator/enroll-withdrawal/new-students',
'/api/v1/administrator/promotions',
'/api/v1/administrator/promotions/summary',
'/api/v1/administrator/promotions/levels',
'/api/v1/administrator/promotions/1',
'/api/v1/administrator/promotions/1/reminders',
'/api/v1/administrator/promotions/1/audit',
'/api/v1/administrator/emergency-contacts',
'/api/v1/administrator/emergency-contacts/data',
'/api/v1/administrator/emergency-contacts/1',
'/api/v1/administrator/staff',
'/api/v1/administrator/staff/1',
'/api/v1/administrator/flags/pending',
'/api/v1/administrator/flags/form-meta',
'/api/v1/administrator/flags/students/1',
'/api/v1/administrator/flags/processed',
'/api/v1/administrator/flags/analysis',
'/api/v1/administrator/grading/homework-tracking?class_section_id=' . $classSectionId,
'/api/v1/administrator/expenses',
'/api/v1/administrator/expenses/options',
'/api/v1/administrator/expenses/1',
'/api/v1/administrator/reports/financial-detailed',
'/api/v1/administrator/reports/stakeholder-analysis',
'/api/v1/administrator/reports/parent-payment-followups',
'/api/v1/administrator/invoices/management',
'/api/v1/administrator/paypal-transactions',
'/api/v1/administrator/reimbursements/under-processing',
'/api/v1/administrator/reimbursements/export',
'/api/v1/administrator/reimbursements/reimbursed-expenses',
'/api/v1/administrator/reimbursements',
'/api/v1/administrator/printables/badges/form-options',
'/api/v1/administrator/printables/report-card/meta',
'/api/v1/administrator/certificates/dashboard',
'/api/v1/administrator/certificates/form-options',
'/api/v1/administrator/certificates/audit-log',
'/api/v1/administrator/certificates/reprint/CERT-E2E-001',
'/api/v1/administrator/role-permission/roles',
'/api/v1/administrator/role-permission/roles/1',
'/api/v1/administrator/role-permission/users',
'/api/v1/administrator/role-permission/permissions',
'/api/v1/administrator/role-permission/permissions/1',
'/api/v1/administrator/role-permission/roles/1/permissions',
'/api/v1/school-years',
'/api/v1/school-years/current',
'/api/v1/school-years/options',
'/api/v1/school-years/1/summary',
'/api/v1/school-years/1/parent-balances',
'/api/v1/school-years/1/promotion-preview',
'/api/v1/class-sections',
'/api/v1/classes',
'/api/v1/users',
'/api/v1/users/' . $world['parent_id'],
'/api/v1/students/' . $studentId,
];
}
/**
* @param array<string, mixed> $world
* @return list<array{0: string, 1: string, 2: array<string, mixed>}>
*/
private function adminMutationEndpoints(array $world, int $adminId): array
{
$studentId = (int) $world['student_id'];
$teacherId = (int) $world['teacher']->id;
$classSectionId = (int) $world['class_section_id'];
return [
['POST', '/api/v1/administrator/absence', ['user_id' => $adminId, 'date' => '2025-10-05', 'reason' => 'Contract probe']],
['POST', '/api/v1/administrator/teacher-submissions/notify', ['teacher_id' => $teacherId, 'message' => 'Contract probe']],
['POST', '/api/v1/administrator/teacher-class/assign', ['teacher_id' => $teacherId, 'class_section_id' => $classSectionId, 'position' => 'main']],
['POST', '/api/v1/administrator/teacher-class/delete', ['teacher_id' => $teacherId, 'class_section_id' => $classSectionId]],
['POST', '/api/v1/administrator/notifications/alerts', ['alerts' => ['contract_probe' => true]]],
['POST', '/api/v1/administrator/notifications/print-recipients', ['recipients' => [$adminId]]],
['POST', '/api/v1/administrator/enrollment-withdrawal/update-statuses', ['enrollment_status' => [$studentId => 'enrolled']]],
['POST', '/api/v1/administrator/promotions/evaluate', ['student_ids' => [$studentId]]],
['POST', '/api/v1/administrator/promotions/reminders/dispatch', ['dry_run' => true]],
['POST', '/api/v1/administrator/promotions/deadlines/expire', ['dry_run' => true]],
['POST', '/api/v1/administrator/promotions/deadlines', ['deadline' => '2025-12-01']],
['POST', '/api/v1/administrator/promotions/placement/preview', ['student_ids' => [$studentId]]],
['POST', '/api/v1/administrator/promotions/placement/batches/1/finalize', ['confirm' => true]],
['POST', '/api/v1/administrator/promotions/levels', ['from_level' => '1', 'to_level' => '2']],
['POST', '/api/v1/administrator/promotions/levels/seed', ['confirm' => true]],
['PATCH', '/api/v1/administrator/promotions/1/status', ['status' => 'approved']],
['PATCH', '/api/v1/administrator/promotions/1/enrollment-steps', ['steps' => ['confirmed' => true]]],
['PATCH', '/api/v1/administrator/emergency-contacts/1', ['name' => 'Contract Contact', 'phone' => '5551234567']],
['DELETE', '/api/v1/administrator/emergency-contacts/1', []],
['POST', '/api/v1/administrator/staff', ['firstname' => 'Staff', 'lastname' => 'Probe', 'email' => 'staff.probe@example.test']],
['PATCH', '/api/v1/administrator/staff/1', ['firstname' => 'Staff Updated']],
['DELETE', '/api/v1/administrator/staff/1', []],
['POST', '/api/v1/administrator/flags', ['student_id' => $studentId, 'reason' => 'Contract probe']],
['POST', '/api/v1/administrator/flags/1/state', ['state' => 'reviewing']],
['POST', '/api/v1/administrator/flags/1/close', ['resolution' => 'closed']],
['POST', '/api/v1/administrator/flags/1/cancel', ['reason' => 'cancelled']],
['POST', '/api/v1/school-years', ['name' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-06-30']],
['PATCH', '/api/v1/school-years/1', ['status' => 'active']],
['POST', '/api/v1/school-years/1/validate-close', ['confirm' => true]],
['POST', '/api/v1/school-years/1/preview-close', ['confirm' => true]],
['POST', '/api/v1/school-years/1/close', ['confirm' => true]],
['POST', '/api/v1/school-years/1/reopen', ['confirm' => true]],
];
}
}
@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Http\UploadedFile;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiAttachmentUploadContentSafetyContractTest extends FullSurfaceE2EContractCase
{
public function test_upload_endpoints_reject_executable_or_misleading_files_cleanly(): void
{
$routes = $this->apiRoutesContainingAny(['upload', 'import', 'attachment', 'document', 'avatar', 'photo', 'file']);
$this->assertNotEmpty($routes, 'Upload-like routes should be covered by content safety tests.');
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
foreach (['file', 'attachment', 'document', 'import_file', 'avatar'] as $field) {
$payload = $this->payloadFor($method, $route->uri()) + [
$field => UploadedFile::fake()->create('malware.php', 4, 'application/x-php'),
];
$response = $this->actingAs($this->actorFor($route->uri()), 'api')
->json($method, $this->materializePath($route->uri()), $payload);
$this->assertStatusIn($response, [400, 403, 404, 409, 413, 415, 422], "{$method} {$route->uri()} should reject executable {$field}.");
$this->assertStringNotContainsString('<?php', $response->getContent());
}
}
}
public function test_download_and_file_response_routes_do_not_allow_path_traversal_parameters(): void
{
foreach ($this->apiRoutesContainingAny(['download', 'export', 'file', 'document', 'receipt', 'certificate']) as $route) {
$method = $this->primaryMethod($route);
if ($method !== 'GET') {
continue;
}
$path = $this->materializePath($route->uri()) . '?filename=../../.env&path=../../storage/logs/laravel.log';
$response = $this->actingAs($this->actorFor($route->uri()), 'api')->getJson($path);
$this->assertStatusIn($response, [200, 204, 400, 401, 403, 404, 409, 422], "GET {$route->uri()} path traversal probe");
$body = $response->getContent();
$this->assertStringNotContainsString('APP_KEY=', $body);
$this->assertStringNotContainsString('DB_PASSWORD=', $body);
}
}
}
@@ -0,0 +1,46 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiAuditLogAndAdministrativeTraceabilityContractTest extends FullSurfaceE2EContractCase
{
public function test_admin_mutations_return_traceable_status_without_exposing_internal_log_records(): void
{
$routes = $this->apiRoutesContainingAny(['users', 'students', 'school-years', 'finance', 'inventory', 'permissions', 'roles', 'settings']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$uri = $route->uri();
$response = $this->requestAs($this->admin, $method, $uri, $this->payloadFor($method, $uri));
$this->assertNoServerError($response, "$method $uri admin mutation traceability");
$this->assertControlled($response, $method, $uri);
$this->assertStringNotContainsStringIgnoringCase('audit_log_id', $response->getContent(), $uri . ' should not leak raw internal audit identifiers by accident.');
$this->assertStringNotContainsStringIgnoringCase('stack_trace', $response->getContent(), $uri . ' should not leak stack traces in mutation responses.');
}
}
public function test_audit_and_log_routes_are_not_mutable_by_public_or_low_privilege_users(): void
{
$routes = $this->apiRoutesContainingAny(['audit', 'logs', 'activity', 'history']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$public = $this->json($method, $this->materializePath($uri), $this->payloadFor($method, $uri));
$parent = $this->requestAs($this->parent, $method, $uri, $this->payloadFor($method, $uri));
$this->assertNoServerError($public, "$method $uri public audit route");
$this->assertNoServerError($parent, "$method $uri parent audit route");
$this->assertStatusIn($public, [302, 400, 401, 403, 404, 405, 419, 422], "$method $uri public audit route");
$this->assertStatusIn($parent, [302, 400, 401, 403, 404, 405, 419, 422], "$method $uri parent audit route");
}
}
}
@@ -0,0 +1,90 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiAuthenticationLifecycleDepthContractTest extends FullSurfaceE2EContractCase
{
public function test_authentication_lifecycle_handles_login_me_refresh_logout_and_invalid_credentials_consistently(): void
{
$loginRoutes = [
'/api/v1/auth/login',
'/api/login',
'/user/login',
];
foreach ($loginRoutes as $path) {
$response = $this->postJson($path, [
'email' => $this->admin->email,
'password' => 'password',
]);
$this->assertNoServerError($response, 'valid login at ' . $path);
if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300) {
$this->assertTrue(
$response->json('token') !== null || $response->json('access_token') !== null || $response->json('user') !== null,
$path . ' must return a usable login contract, not a mystery envelope.'
);
}
}
foreach ($loginRoutes as $path) {
$response = $this->postJson($path, [
'email' => $this->admin->email,
'password' => 'definitely-wrong-password',
]);
$this->assertStatusIn($response, [400, 401, 403, 419, 422], 'invalid login at ' . $path);
$this->assertNoServerError($response, 'invalid login at ' . $path);
}
foreach (['/api/v1/auth/me', '/api/v1/me', '/api/user'] as $path) {
$response = $this->actingAs($this->admin, 'api')->getJson($path);
$this->assertNoServerError($response, 'authenticated identity lookup at ' . $path);
if ($response->getStatusCode() === 200) {
$this->assertTrue(
$response->json('user') !== null || $response->json('id') !== null || $response->json('data') !== null,
$path . ' must expose a stable identity payload.'
);
}
}
foreach (['/api/v1/auth/logout', '/api/logout', '/user/logout'] as $path) {
$first = $this->actingAs($this->admin, 'api')->postJson($path);
$second = $this->actingAs($this->admin, 'api')->postJson($path);
$this->assertNoServerError($first, 'first logout at ' . $path);
$this->assertNoServerError($second, 'repeat logout at ' . $path);
$this->assertStatusIn($first, [200, 202, 204, 302, 401, 404, 419, 422], 'first logout at ' . $path);
$this->assertStatusIn($second, [200, 202, 204, 302, 401, 404, 419, 422], 'repeat logout at ' . $path);
}
}
public function test_password_reset_and_registration_like_routes_do_not_leak_account_existence(): void
{
$routes = $this->apiRoutesContainingAny(['forgot', 'password', 'reset', 'register', 'verify']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$uri = $route->uri();
$response = $this->json($method, $this->materializePath($uri), [
'email' => 'nobody.' . uniqid() . '@example.test',
'password' => 'password',
'password_confirmation' => 'not-the-same',
'token' => 'not-a-real-token',
]);
$this->assertControlled($response, $method, $uri);
$this->assertNoServerError($response, $method . ' ' . $uri);
$this->assertStringNotContainsStringIgnoringCase('select * from', $response->getContent(), $uri . ' must not leak account lookup SQL.');
$this->assertStringNotContainsStringIgnoringCase('No query results for model', $response->getContent(), $uri . ' must not leak framework model lookups.');
}
}
}
@@ -0,0 +1,29 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiAuthorizationCacheInvalidationContractTest extends FullSurfaceE2EContractCase
{
public function test_role_and_permission_mutations_do_not_make_low_privilege_actor_admin_by_cache_accident(): void
{
$privilegedRoutes = array_slice($this->apiRoutesContainingAny(['roles', 'permissions', 'users', 'settings', 'school-years', 'finance', 'inventory']), 0, 100);
foreach ($privilegedRoutes as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'role' => 'administrator',
'roles' => ['administrator'],
'permissions' => ['*', 'admin.*', 'users.delete'],
'is_admin' => true,
];
$response = $this->requestAs($this->parent, $method, $uri, $payload);
$this->assertStatusIn($response, [401, 403, 404, 405, 419, 422], 'parent privilege escalation should be blocked at ' . $method . ' ' . $uri);
$this->assertNoServerError($response, 'permission cache escalation probe at ' . $method . ' ' . $uri);
}
}
}
@@ -0,0 +1,59 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch11RouteRegressionNetTest extends FullSurfaceE2EContractCase
{
public function test_every_api_family_has_at_least_one_negative_contract_probe_in_batch_11(): void
{
$families = [
'auth' => ['auth', 'login', 'logout', 'frontend'],
'school_year' => ['school-year', 'school_year', 'semester'],
'academic' => ['scores', 'grading', 'homework', 'quiz', 'report-card'],
'attendance' => ['attendance', 'absence', 'late', 'dismissal'],
'finance' => ['finance', 'invoice', 'payment', 'refund'],
'inventory' => ['inventory', 'supplier', 'supply'],
'communications' => ['messages', 'support', 'email', 'whatsapp', 'notification'],
'family' => ['parent', 'family', 'guardian', 'authorized'],
'admin' => ['admin', 'users', 'roles', 'permissions', 'settings'],
'files' => ['import', 'export', 'download', 'print', 'certificate', 'badge'],
];
foreach ($families as $family => $needles) {
$this->assertNotEmpty(
$this->apiRoutesContainingAny($needles),
'Batch 11 expects at least one route for the ' . $family . ' family. If this fails, update the route catalog instead of pretending the family vanished.'
);
}
}
public function test_batch_11_high_risk_routes_return_controlled_responses_under_combined_hostile_input(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['auth', 'attendance', 'finance', 'students', 'users', 'settings', 'export', 'import', 'webhook', 'messages']), 0, 160);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'id' => '../../../etc/passwd',
'student_id' => ['bad' => true],
'amount' => 'Infinity',
'notes' => '<script>alert(1)</script>',
'url' => 'http://169.254.169.254/latest/meta-data/',
'return_url' => 'https://evil.example',
];
$this->actingAs($this->actorFor($uri), 'api');
$response = $this->withHeaders([
'Accept' => 'application/json',
'X-Forwarded-For' => '127.0.0.1',
'Idempotency-Key' => 'batch-11-regression-net',
])->json($method, $this->materializePath($uri), $payload);
$this->assertControlled($response, $method, $uri);
$this->assertNoServerError($response, 'batch 11 combined hostile probe at ' . $method . ' ' . $uri);
}
}
}
@@ -0,0 +1,49 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12AcademicGradeBoundaryContractTest extends FullSurfaceE2EContractCase
{
public function test_grade_and_score_routes_reject_out_of_range_and_wrong_student_contexts(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['score', 'grade', 'homework', 'quiz', 'project', 'midterm', 'final', 'participation', 'report-card']), 0, 160);
$scores = [-1, 101, 'A+++', 'NaN', 'Infinity', 999999];
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
foreach ($scores as $score) {
$response = $this->requestAs($this->teacher, $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'score' => $score,
'points' => $score,
'student_id' => $this->ids['invoiceId'],
'class_section_id' => $this->ids['invoiceId'],
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'academic grade boundary '.$method.' '.$route->uri());
}
}
}
public function test_locked_or_finalized_grade_routes_do_not_accept_low_privilege_overrides(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['lock', 'unlock', 'finalize', 'publish', 'report-card']), 0, 100);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$response = $this->requestAs($this->parent, $method, $route->uri(), $this->payloadFor($method, $route->uri()) + ['force' => true, 'locked' => false]);
$this->assertStatusIn($response, [401, 403, 404, 405, 409, 419, 422], 'parent grade finalization override '.$method.' '.$route->uri());
$this->assertNoServerError($response, 'parent grade finalization override '.$method.' '.$route->uri());
}
}
}
@@ -0,0 +1,56 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
use Illuminate\Http\UploadedFile;
class ApiBatch12AttachmentLifecycleContractTest extends FullSurfaceE2EContractCase
{
public function test_attachment_upload_routes_handle_multiple_file_shapes_cleanly(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['upload', 'attachment', 'document', 'import', 'media', 'file']), 0, 120);
$files = [
UploadedFile::fake()->create('normal.pdf', 12, 'application/pdf'),
UploadedFile::fake()->create('image-as-pdf.pdf', 12, 'image/png'),
UploadedFile::fake()->create('script.php', 1, 'application/x-php'),
];
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
foreach ($files as $file) {
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'file' => $file,
'attachment' => $file,
'document' => $file,
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'attachment lifecycle '.$method.' '.$route->uri());
}
}
}
public function test_file_download_routes_reject_path_and_storage_driver_probing(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['download', 'file', 'media', 'receipt', 'certificate', 'badge']), 0, 100);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), [
'path' => '../../.env',
'disk' => 's3',
'filename' => '../storage/logs/laravel.log',
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'file path probe '.$method.' '.$route->uri());
$this->assertStringNotContainsString('APP_KEY', $response->getContent(), 'download route exposed environment content');
}
}
}
@@ -0,0 +1,51 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12AttendanceCorrectionAuditContractTest extends FullSurfaceE2EContractCase
{
public function test_attendance_corrections_require_valid_actor_date_status_and_reason(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['attendance', 'absence', 'late', 'dismissal', 'slip']), 0, 150);
$payloads = [
['status' => 'present', 'reason' => ''],
['status' => 'teleported', 'reason' => 'nonsense'],
['date' => '1900-01-01', 'reason' => 'too old'],
['date' => '2099-12-31', 'reason' => 'future'],
['student_id' => $this->ids['invoiceId'], 'reason' => 'wrong id type'],
];
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
foreach ($payloads as $payload) {
$response = $this->requestAs($this->teacher, $method, $route->uri(), $this->payloadFor($method, $route->uri()) + $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'attendance correction audit '.$method.' '.$route->uri());
}
}
}
public function test_parent_and_student_cannot_correct_attendance_records(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['attendance', 'absence', 'late', 'dismissal']), 0, 100);
foreach ([$this->parent, $this->studentUser] as $actor) {
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$response = $this->requestAs($actor, $method, $route->uri(), $this->payloadFor($method, $route->uri()));
$this->assertStatusIn($response, [401, 403, 404, 405, 409, 419, 422], 'low privilege attendance correction '.$method.' '.$route->uri());
$this->assertNoServerError($response, 'low privilege attendance correction '.$method.' '.$route->uri());
}
}
}
}
@@ -0,0 +1,47 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12BulkMutationBoundaryContractTest extends FullSurfaceE2EContractCase
{
public function test_bulk_mutation_routes_reject_empty_and_cross_domain_targets_cleanly(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['bulk', 'batch', 'assign', 'remove', 'delete', 'archive', 'restore', 'import']), 0, 140);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
foreach ([
['ids' => []],
['ids' => [$this->ids['studentId'], $this->ids['invoiceId'], $this->ids['classSectionId']]],
['student_ids' => [$this->ids['invoiceId']], 'invoice_ids' => [$this->ids['studentId']]],
] as $payload) {
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'bulk boundary '.$method.' '.$route->uri());
}
}
}
public function test_bulk_routes_do_not_accept_unbounded_target_lists(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['bulk', 'batch', 'assign', 'import']), 0, 100);
$ids = range(1, 750);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + ['ids' => $ids, 'student_ids' => $ids]);
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'unbounded bulk list '.$method.' '.$route->uri());
}
}
}
@@ -0,0 +1,51 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12CombinedRegressionSweepTest extends FullSurfaceE2EContractCase
{
public function test_batch_12_combined_security_business_rule_and_contract_probe(): void
{
$routes = array_slice($this->apiRoutesContainingAny([
'auth', 'users', 'roles', 'permissions', 'students', 'parents', 'family',
'attendance', 'scores', 'report-card', 'finance', 'invoice', 'payment',
'inventory', 'import', 'export', 'webhook', 'settings', 'messages',
]), 0, 220);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'scope' => 'all',
'force' => true,
'is_admin' => true,
'role' => 'administrator',
'student_id' => $this->ids['invoiceId'],
'invoice_id' => $this->ids['studentId'],
'amount' => '1e309',
'status' => '<script>alert(1)</script>',
'return_url' => 'https://evil.example/after-login',
'callback_url' => 'http://169.254.169.254/latest/meta-data/',
'fields' => ['*', 'password', 'remember_token'],
];
$this->actingAs($this->actorFor($uri), 'api');
$response = $this->withHeaders([
'Accept' => 'application/json',
'X-Admin' => '1',
'X-Original-Method' => 'DELETE',
'Idempotency-Key' => 'batch-12-combined-regression',
])->json($method, $this->materializePath($uri), $payload);
$this->assertControlled($response, $method, $uri);
$this->assertNoServerError($response, 'batch 12 combined regression '.$method.' '.$uri);
$body = $response->getContent();
$this->assertStringNotContainsString('SQLSTATE', $body, 'batch 12 response leaked SQL at '.$uri);
$this->assertStringNotContainsString('APP_KEY', $body, 'batch 12 response leaked env at '.$uri);
$this->assertStringNotContainsString('remember_token', strtolower($body), 'batch 12 response leaked auth internals at '.$uri);
}
}
}
@@ -0,0 +1,46 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12ConsistencyUnderPartialPayloadContractTest extends FullSurfaceE2EContractCase
{
public function test_patch_routes_do_not_null_identity_or_ownership_when_optional_fields_are_missing(): void
{
$routes = array_slice($this->apiRoutes(), 0, 250);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['PUT', 'PATCH'], true)) {
continue;
}
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), ['notes' => 'partial batch 12 update']);
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'partial payload update '.$method.' '.$route->uri());
if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300) {
$body = strtolower($response->getContent());
$this->assertStringNotContainsString('"id":null', $body, 'successful partial update nulled id');
$this->assertStringNotContainsString('"user_id":null', $body, 'successful partial update nulled owner');
}
}
}
public function test_post_routes_handle_sparse_payloads_as_validation_not_crashes(): void
{
$routes = array_slice($this->apiRoutes(), 0, 250);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if ($method !== 'POST') {
continue;
}
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), ['name' => null]);
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'sparse post payload '.$method.' '.$route->uri());
}
}
}
@@ -0,0 +1,53 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12CrossActorDataLeakageContractTest extends FullSurfaceE2EContractCase
{
public function test_parent_teacher_and_student_scoped_reads_do_not_leak_admin_only_fields(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['students', 'parents', 'teacher', 'attendance', 'invoice', 'report-card', 'messages', 'profile']), 0, 150);
$sensitiveKeys = ['salary', 'permissions', 'roles_raw', 'password', 'remember_token', 'deleted_at', 'created_by', 'updated_by', 'internal_notes'];
foreach ([$this->parent, $this->teacher, $this->studentUser] as $actor) {
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if ($method !== 'GET') {
continue;
}
$response = $this->requestAs($actor, $method, $route->uri());
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'cross actor leak '.$method.' '.$route->uri());
$body = strtolower($response->getContent());
foreach ($sensitiveKeys as $key) {
$this->assertStringNotContainsString($key, $body, $route->uri().' leaked sensitive key '.$key);
}
}
}
}
public function test_actor_cannot_select_a_different_owner_scope_with_query_parameters(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['students', 'attendance', 'invoice', 'family', 'messages', 'report-card']), 0, 120);
foreach ($routes as $route) {
if ($this->primaryMethod($route) !== 'GET') {
continue;
}
$response = $this->actingAs($this->parent, 'api')->json('GET', $this->materializePath($route->uri()), [
'parent_id' => $this->ids['parentId'] + 999,
'student_id' => $this->ids['studentId'] + 999,
'family_id' => $this->ids['familyId'] + 999,
'scope' => 'all',
]);
$this->assertControlled($response, 'GET', $route->uri());
$this->assertNoServerError($response, 'owner query override '.$route->uri());
}
}
}
@@ -0,0 +1,51 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12FinancialLedgerIntegrityContractTest extends FullSurfaceE2EContractCase
{
public function test_financial_mutations_reject_ledger_breaking_amounts_and_cross_invoice_ids(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['finance', 'invoice', 'payment', 'refund', 'charge', 'installment', 'fee']), 0, 150);
$amounts = ['-0.01', '-999999', '999999999999999999999', '1e309', 'NaN', 'Infinity', '0.00000001'];
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
foreach ($amounts as $amount) {
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'amount' => $amount,
'paid_amount' => $amount,
'balance' => $amount,
'invoice_id' => $this->ids['studentId'],
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'financial ledger boundary '.$method.' '.$route->uri());
$this->assertStringNotContainsString('SQLSTATE', $response->getContent(), 'financial route leaked SQL');
}
}
}
public function test_parent_cannot_force_finance_scope_to_all_families(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['invoice', 'payment', 'finance', 'receipt', 'balance']), 0, 100);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->parent, $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'scope' => 'all',
'parent_id' => $this->ids['parentId'] + 777,
'include_voided' => true,
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'parent finance scope override '.$method.' '.$route->uri());
}
}
}
@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12GuardianDelegationExpirationContractTest extends FullSurfaceE2EContractCase
{
public function test_guardian_and_authorized_user_routes_validate_expiration_relationship_and_scope(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['guardian', 'authorized', 'family', 'pickup', 'whatsapp']), 0, 130);
$payloads = [
['expires_at' => '1900-01-01', 'relationship' => ''],
['expires_at' => 'not-a-date', 'relationship' => '<script>alert(1)</script>'],
['student_id' => $this->ids['invoiceId'], 'parent_id' => $this->ids['teacherId']],
['can_pickup' => 'yes please', 'can_receive_messages' => ['bad' => true]],
];
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
foreach ($payloads as $payload) {
$response = $this->requestAs($this->parent, $method, $route->uri(), $this->payloadFor($method, $route->uri()) + $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'guardian delegation '.$method.' '.$route->uri());
}
}
}
public function test_student_cannot_manage_guardians_or_authorized_users(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['guardian', 'authorized', 'family']), 0, 100);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$response = $this->requestAs($this->studentUser, $method, $route->uri(), $this->payloadFor($method, $route->uri()));
$this->assertStatusIn($response, [401, 403, 404, 405, 419, 422], 'student guardian mutation '.$method.' '.$route->uri());
$this->assertNoServerError($response, 'student guardian mutation '.$method.' '.$route->uri());
}
}
}
@@ -0,0 +1,41 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12LongRunningExportJobContractTest extends FullSurfaceE2EContractCase
{
public function test_export_report_and_generation_routes_handle_async_parameters_without_crashing(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['export', 'download', 'print', 'generate', 'report', 'certificate', 'badge', 'receipt']), 0, 160);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$payload = $this->payloadFor($method, $route->uri()) + [
'async' => true,
'notify_when_ready' => true,
'format' => 'xlsx',
'fields' => ['*', 'password', 'remember_token'],
'filters' => ['scope' => 'all', 'school_year' => self::E2E_SCHOOL_YEAR],
];
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'long running export job '.$method.' '.$route->uri());
$this->assertStringNotContainsString('remember_token', $response->getContent(), 'export route exposed sensitive fields');
}
}
public function test_low_privilege_users_cannot_request_full_school_exports(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['export', 'download', 'report']), 0, 120);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->parent, $method, $route->uri(), ['scope' => 'all', 'include' => ['students', 'finance', 'users']]);
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'low privilege full export '.$method.' '.$route->uri());
}
}
}
@@ -0,0 +1,47 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12MaintenanceModeAndOperationalControlContractTest extends FullSurfaceE2EContractCase
{
public function test_operational_control_routes_are_admin_only_and_controlled(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['maintenance', 'cache', 'queue', 'job', 'horizon', 'telescope', 'metrics', 'logs', 'debug']), 0, 120);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$parentResponse = $this->requestAs($this->parent, $method, $uri, $this->payloadFor($method, $uri));
$this->assertStatusIn($parentResponse, [401, 403, 404, 405, 419, 422], 'parent must not control operational endpoint '.$method.' '.$uri);
$this->assertNoServerError($parentResponse, 'parent operational probe '.$method.' '.$uri);
$adminResponse = $this->requestAs($this->admin, $method, $uri, $this->payloadFor($method, $uri));
$this->assertControlled($adminResponse, $method, $uri);
$this->assertNoServerError($adminResponse, 'admin operational probe '.$method.' '.$uri);
}
}
public function test_public_requests_cannot_toggle_system_state(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['maintenance', 'cache', 'queue', 'config', 'settings']), 0, 80);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$response = $this->json($method, $this->materializePath($route->uri()), [
'enabled' => true,
'force' => true,
'confirm' => true,
]);
$this->assertStatusIn($response, [401, 403, 404, 405, 419, 422], 'public system toggle must be blocked '.$method.' '.$route->uri());
$this->assertNoServerError($response, 'public system toggle '.$method.' '.$route->uri());
}
}
}
@@ -0,0 +1,39 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12RouteMetadataRiskRegisterTest extends FullSurfaceE2EContractCase
{
public function test_high_risk_route_families_exist_in_the_contract_risk_register(): void
{
$riskRegister = [
'identity' => ['auth', 'login', 'logout', 'profile', 'frontend'],
'authorization' => ['roles', 'permissions', 'switch-role', 'impersonate'],
'student_privacy' => ['students', 'parents', 'family', 'guardian'],
'attendance_safety' => ['attendance', 'late', 'dismissal', 'absence'],
'academic_records' => ['scores', 'grades', 'report-card', 'certificate'],
'financial_records' => ['finance', 'invoice', 'payment', 'refund', 'receipt'],
'files' => ['upload', 'download', 'import', 'export', 'print'],
'external_integrations' => ['webhook', 'callback', 'paypal', 'email', 'whatsapp'],
'operations' => ['settings', 'configuration', 'health', 'debug', 'logs'],
];
foreach ($riskRegister as $risk => $needles) {
$this->assertNotEmpty($this->apiRoutesContainingAny($needles), 'Batch 12 risk register missing route family: '.$risk);
}
}
public function test_mutating_high_risk_routes_have_specific_route_names_when_available(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['finance', 'attendance', 'users', 'roles', 'permissions', 'settings', 'import', 'webhook']), 0, 180) as $route) {
if (! in_array($this->primaryMethod($route), ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$name = $route->getName();
$this->assertTrue($name === null || ! preg_match('/(^|\.)(store|update|destroy)$/', $name) || substr_count((string) $name, '.') >= 2, 'High-risk route name is too generic: '.($name ?? '[unnamed]').' '.$route->uri());
}
}
}
@@ -0,0 +1,27 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12SearchEnumerationResistanceContractTest extends FullSurfaceE2EContractCase
{
public function test_search_autocomplete_and_lookup_routes_resist_identifier_enumeration(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['search', 'lookup', 'autocomplete', 'find', 'students', 'users', 'parents']), 0, 150);
$queries = ['a', '%', '_', '*', '000000', $this->admin->email, ' OR 1=1 --', '<img src=x onerror=alert(1)>'];
foreach ($routes as $route) {
if ($this->primaryMethod($route) !== 'GET') {
continue;
}
foreach ($queries as $query) {
$response = $this->requestAs($this->parent, 'GET', $route->uri(), ['search' => $query, 'q' => $query, 'email' => $query]);
$this->assertControlled($response, 'GET', $route->uri());
$this->assertNoServerError($response, 'enumeration resistant search '.$route->uri());
$this->assertStringNotContainsString('SQLSTATE', $response->getContent(), 'search leaked SQL');
}
}
}
}
@@ -0,0 +1,43 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12SensitiveConfigurationExposureContractTest extends FullSurfaceE2EContractCase
{
public function test_configuration_and_bootstrap_responses_do_not_expose_secrets(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['settings', 'configuration', 'config', 'frontend', 'bootstrap', 'env', 'health', 'debug']), 0, 120);
$forbidden = ['APP_KEY', 'DB_PASSWORD', 'MAIL_PASSWORD', 'STRIPE_SECRET', 'PAYPAL_SECRET', 'JWT_SECRET', 'SANCTUM', 'password_hash', 'remember_token'];
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if ($method !== 'GET') {
continue;
}
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri());
$this->assertControlled($response, $method, $route->uri());
$this->assertNoServerError($response, 'secret exposure probe '.$route->uri());
$body = $response->getContent();
foreach ($forbidden as $needle) {
$this->assertStringNotContainsString($needle, $body, $route->uri().' must not expose '.$needle);
}
}
}
public function test_low_privilege_users_cannot_read_sensitive_configuration_domains(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['settings', 'configuration', 'config', 'permissions']), 0, 100);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->studentUser, $method, $route->uri(), $this->payloadFor($method, $route->uri()));
$this->assertStatusIn($response, [401, 403, 404, 405, 419, 422], 'student must not read/mutate sensitive configuration '.$method.' '.$route->uri());
$this->assertNoServerError($response, 'student configuration probe '.$method.' '.$route->uri());
}
}
}
@@ -0,0 +1,39 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch12WebhookProviderMatrixContractTest extends FullSurfaceE2EContractCase
{
public function test_provider_callbacks_reject_missing_mismatched_and_replayed_signatures(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['webhook', 'callback', 'paypal', 'stripe', 'payment', 'provider']), 0, 120);
$headerSets = [
[],
['X-Signature' => 'bad-signature'],
['PayPal-Transmission-Id' => 'replay-id', 'PayPal-Transmission-Sig' => 'bad'],
['Stripe-Signature' => 't=1,v1=bad'],
];
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
foreach ($headerSets as $headers) {
$response = $this->withHeaders($headers)->json($method, $this->materializePath($route->uri()), [
'id' => 'evt_batch_12',
'type' => 'payment.succeeded',
'amount' => 10,
'metadata' => ['invoice_id' => $this->ids['invoiceId']],
]);
$this->assertStatusIn($response, [200, 202, 400, 401, 403, 404, 405, 409, 419, 422], 'provider callback '.$method.' '.$route->uri());
$this->assertNoServerError($response, 'provider callback '.$method.' '.$route->uri());
$this->assertStringNotContainsString('secret', strtolower($response->getContent()), 'provider callback must not expose secrets');
}
}
}
}
@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13AccountRecoveryCredentialChangeContractTest extends FullSurfaceE2EContractCase
{
public function test_password_reset_and_credential_routes_do_not_leak_user_existence_or_tokens(): void
{
$routes = $this->apiRoutesContainingAny(['password', 'reset', 'forgot', 'verify', 'verification', 'credential', 'profile']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$payload = $this->payloadFor($method, $route->uri()) + [
'email' => 'does-not-exist-batch13@example.test',
'token' => str_repeat('x', 2048),
'password' => 'short',
'password_confirmation' => 'different',
'current_password' => 'wrong-password',
];
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
$this->assertStringNotContainsString('password_resets', $body);
$this->assertStringNotContainsString('remember_token', $body);
$this->assertStringNotContainsString('reset_token', $body);
}
}
public function test_low_privilege_users_cannot_change_other_users_credentials(): void
{
$routes = $this->apiRoutesContainingAny(['users', 'profile', 'password', 'credential']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$response = $this->requestAs($this->parent, $method, $route->uri(), [
'user_id' => $this->admin->id,
'email' => $this->admin->email,
'password' => 'NewPassword123!',
'password_confirmation' => 'NewPassword123!',
'role' => 'administrator',
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertNotContains($response->getStatusCode(), [200, 201]);
}
}
}
@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13ApiPaginationPerformanceGuardContractTest extends FullSurfaceE2EContractCase
{
public function test_list_routes_cap_unbounded_pagination_and_include_requests(): void
{
$routes = array_filter($this->apiRoutes(), fn ($route) => $this->primaryMethod($route) === 'GET');
foreach (array_slice(array_values($routes), 0, 80) as $route) {
$uri = $route->uri();
$response = $this->requestAs($this->actorFor($uri), 'GET', $uri, [
'page' => 1,
'per_page' => 1000000,
'limit' => 1000000,
'include' => implode(',', array_fill(0, 100, 'children.children.children')),
'expand' => '*',
'fields' => '*',
]);
$this->assertControlled($response, 'GET', $uri);
$this->assertStringNotContainsString('Allowed memory size', $response->getContent());
$this->assertStringNotContainsString('Maximum execution time', $response->getContent());
}
}
public function test_search_routes_handle_unicode_and_extreme_terms_without_leaking_sql(): void
{
$routes = $this->apiRoutesContainingAny(['search', 'lookup', 'autocomplete', 'students', 'users', 'parents']);
foreach ($routes as $route) {
if ($this->primaryMethod($route) !== 'GET') {
continue;
}
$response = $this->requestAs($this->actorFor($route->uri()), 'GET', $route->uri(), [
'q' => str_repeat('أ', 1000)."%' OR 1=1 --",
'search' => str_repeat('🧪', 300),
]);
$this->assertControlled($response, 'GET', $route->uri());
$this->assertStringNotContainsString('SQLSTATE', $response->getContent());
}
}
}
@@ -0,0 +1,56 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13ContactMessagingAbuseModerationContractTest extends FullSurfaceE2EContractCase
{
public function test_message_and_contact_routes_handle_spam_headers_and_html_payloads(): void
{
$routes = $this->apiRoutesContainingAny(['message', 'contact', 'support', 'communication', 'broadcast', 'email', 'whatsapp']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'subject' => "Batch 13\r\nBcc: victim@example.test",
'message' => '<img src=x onerror=alert(1)>'.str_repeat('spam ', 2000),
'body' => '<script>alert("batch13")</script>',
'from' => 'attacker@example.test\r\nCc: injected@example.test',
'recipients' => array_fill(0, 500, 'victim@example.test'),
'channel' => 'all',
];
$response = $this->requestAs($this->parent, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('Bcc: victim@example.test', $response->getContent());
$this->assertStringNotContainsString('<script>alert("batch13")</script>', $response->getContent());
}
}
public function test_broadcast_routes_remain_admin_only_under_channel_spoofing(): void
{
$routes = $this->apiRoutesContainingAny(['broadcast', 'email', 'notification', 'whatsapp']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$response = $this->requestAs($this->teacher, $method, $route->uri(), [
'recipients' => ['all'],
'audience' => 'all_school',
'channel' => 'admin',
'force' => true,
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertNotContains($response->getStatusCode(), [200, 201]);
}
}
}
@@ -0,0 +1,58 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13DiscountVoucherScholarshipAbuseContractTest extends FullSurfaceE2EContractCase
{
public function test_discount_and_voucher_routes_reject_abusive_amounts_and_scope_overrides(): void
{
$routes = $this->apiRoutesContainingAny(['discount', 'voucher', 'scholarship', 'fee', 'tuition', 'waiver']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'voucher_id' => $this->ids['voucherId'],
'discount_type' => 'percent',
'percentage' => 1000,
'amount' => -9999.99,
'applies_to_all' => true,
'family_id' => 99999999,
'student_id' => 99999999,
'approved_by' => $this->parent->id,
];
$response = $this->requestAs($this->admin, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('SQLSTATE', $response->getContent());
}
}
public function test_parent_cannot_self_grant_discounts_or_scholarships(): void
{
$routes = $this->apiRoutesContainingAny(['discount', 'voucher', 'scholarship', 'waiver']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$response = $this->requestAs($this->parent, $method, $route->uri(), [
'student_id' => $this->ids['studentId'],
'amount' => 9999,
'percentage' => 100,
'approved' => true,
'reason' => 'self-granted',
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertNotContains($response->getStatusCode(), [200, 201]);
}
}
}
@@ -0,0 +1,61 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13FullSurfaceRegressionSuperSweepTest extends FullSurfaceE2EContractCase
{
public function test_batch13_super_sweep_keeps_major_route_families_under_controlled_failure(): void
{
$families = [
'identity' => ['auth', 'login', 'profile', 'users', 'roles', 'permissions'],
'school' => ['students', 'parents', 'families', 'classes', 'school-year', 'semester'],
'academic' => ['teacher', 'scores', 'homework', 'quiz', 'report-card', 'certificate'],
'safety' => ['attendance', 'dismissal', 'late', 'incident', 'emergency'],
'finance' => ['finance', 'invoice', 'payment', 'refund', 'fee', 'discount'],
'operations' => ['inventory', 'supplier', 'procurement', 'settings', 'configuration'],
'communications' => ['message', 'support', 'email', 'whatsapp', 'notification'],
'files' => ['import', 'export', 'download', 'print', 'upload', 'media'],
];
foreach ($families as $needles) {
foreach (array_slice($this->apiRoutesContainingAny($needles), 0, 25) as $route) {
$method = $this->primaryMethod($route);
$payload = $this->payloadFor($method, $route->uri()) + [
'id' => '../../etc/passwd',
'student_id' => 'not-an-int',
'parent_id' => ['array-is-not-id'],
'amount' => 'NaN',
'status' => '<script>bad()</script>',
'include' => '*',
'force' => true,
];
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
$this->assertStringNotContainsString('sqlstate', $body);
$this->assertStringNotContainsString('app_key', $body);
$this->assertStringNotContainsString('stack trace', $body);
}
}
}
public function test_batch13_major_mutation_families_are_represented(): void
{
$expectedFamilies = [
'students', 'attendance', 'scores', 'finance', 'payment', 'inventory',
'message', 'support', 'settings', 'roles', 'permissions', 'family',
];
foreach ($expectedFamilies as $family) {
$mutatingRoutes = array_filter($this->apiRoutesContainingAny([$family]), function ($route): bool {
return in_array($this->primaryMethod($route), ['POST', 'PUT', 'PATCH', 'DELETE'], true);
});
$this->assertNotEmpty($mutatingRoutes, "Batch 13 expected at least one mutating API route for {$family}.");
}
}
}
@@ -0,0 +1,53 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13InventoryProcurementReceivingIntegrityContractTest extends FullSurfaceE2EContractCase
{
public function test_inventory_receiving_routes_reject_negative_or_impossible_stock_movements(): void
{
$routes = $this->apiRoutesContainingAny(['inventory', 'procurement', 'purchase', 'receiving', 'supplier', 'supply']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'item_id' => $this->ids['itemId'],
'supplier_id' => $this->ids['supplierId'],
'quantity' => -1000000,
'received_quantity' => 999999999,
'unit_cost' => -1,
'total_cost' => -999999,
'movement_type' => 'receive',
'force_negative_stock' => true,
];
$response = $this->requestAs($this->admin, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('SQLSTATE', $response->getContent());
}
}
public function test_non_admins_cannot_adjust_inventory_or_procurement_state(): void
{
$routes = $this->apiRoutesContainingAny(['inventory', 'procurement', 'purchase', 'receiving', 'supplier']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
foreach ([$this->teacher, $this->parent, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, $method, $route->uri(), $this->payloadFor($method, $route->uri()));
$this->assertControlled($response, $method, $route->uri());
$this->assertNotContains($response->getStatusCode(), [200, 201]);
}
}
}
}
@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13LocaleCalendarReligiousDateContractTest extends FullSurfaceE2EContractCase
{
public function test_calendar_and_event_routes_handle_locale_specific_dates_cleanly(): void
{
$routes = $this->apiRoutesContainingAny(['calendar', 'event', 'schedule', 'school-year', 'semester', 'holiday']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$payload = $this->payloadFor($method, $route->uri()) + [
'date' => '1447-09-01',
'start_date' => '2025-03-10',
'end_date' => '2025-03-09',
'locale' => 'ar_EG',
'calendar' => 'hijri',
'timezone' => 'America/New_York\0UTC',
'recurrence' => 'FREQ=DAILY;COUNT=999999',
];
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('DateTimeZone::__construct', $response->getContent());
}
}
public function test_attendance_and_academic_routes_reject_out_of_term_religious_calendar_spoofing(): void
{
$routes = $this->apiRoutesContainingAny(['attendance', 'scores', 'homework', 'quiz', 'report-card']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$response = $this->requestAs($this->teacher, $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'date' => '0000-00-00',
'school_year' => '1447/1448',
'semester' => 'ramadan-special<script>',
]);
$this->assertControlled($response, $method, $route->uri());
}
}
}
@@ -0,0 +1,57 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13MobileOfflineSyncConflictContractTest extends FullSurfaceE2EContractCase
{
public function test_offline_sync_headers_do_not_override_authorization_or_state(): void
{
$routes = $this->apiRoutesContainingAny(['attendance', 'scores', 'homework', 'message', 'payment', 'profile']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$this->withHeaders([
'X-Client-Version' => '0.0.1-offline',
'X-Device-Id' => '../shared-device',
'X-Offline-Sequence' => '-999',
'X-Last-Synced-At' => '1900-01-01T00:00:00Z',
'Idempotency-Key' => 'batch13-offline-sync-conflict',
]);
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'client_updated_at' => '1900-01-01T00:00:00Z',
'server_version' => -1,
'force_sync' => true,
'resolve_conflict' => 'client_wins',
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertNotContains($response->getStatusCode(), [500, 501, 502, 503]);
}
}
public function test_duplicate_offline_submissions_have_controlled_outcome(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['attendance', 'scores', 'message']), 0, 20);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + ['offline_uuid' => 'BATCH13-OFFLINE-DUPLICATE'];
$first = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $payload);
$second = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $payload);
$this->assertControlled($first, $method, $route->uri());
$this->assertControlled($second, $method, $route->uri());
}
}
}
@@ -0,0 +1,47 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13ObservableErrorCorrelationContractTest extends FullSurfaceE2EContractCase
{
public function test_malformed_requests_preserve_safe_error_correlation_without_reflecting_header_injection(): void
{
$routes = array_slice($this->apiRoutes(), 0, 100);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$this->withHeaders([
'X-Request-Id' => "batch13\r\nX-Injected: yes",
'X-Correlation-Id' => '<script>correlate()</script>',
]);
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), [
'batch13_malformed' => ['unexpected' => ['deep' => ['value' => str_repeat('x', 4096)]]],
]);
$this->assertControlled($response, $method, $route->uri());
$content = $response->getContent();
$this->assertStringNotContainsString('X-Injected: yes', $content);
$this->assertStringNotContainsString('<script>correlate()</script>', $content);
$this->assertStringNotContainsString('/var/www', $content);
}
}
public function test_error_responses_do_not_disclose_controller_or_model_stack_details(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['students', 'users', 'finance', 'attendance', 'settings']), 0, 60);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), ['id' => '../invalid']);
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
$this->assertStringNotContainsString('app\\http\\controllers', $body);
$this->assertStringNotContainsString('illuminate\\database', $body);
$this->assertStringNotContainsString('vendor/laravel', $body);
}
}
}
@@ -0,0 +1,57 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13ParentPortalInvoicePrivacyContractTest extends FullSurfaceE2EContractCase
{
public function test_parent_finance_queries_cannot_request_all_families_or_other_parent_data(): void
{
$routes = $this->apiRoutesContainingAny(['parent', 'invoice', 'payment', 'finance', 'receipt', 'billing']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$payload = $this->payloadFor($method, $route->uri()) + [
'parent_id' => 99999999,
'family_id' => 99999999,
'student_id' => 99999999,
'all' => true,
'all_families' => true,
'include_balances' => true,
'include_deleted' => true,
'school_year' => 'all',
];
$response = $this->requestAs($this->parent, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('credit_card', strtolower($response->getContent()));
$this->assertStringNotContainsString('bank_account', strtolower($response->getContent()));
$this->assertStringNotContainsString('ssn', strtolower($response->getContent()));
}
}
public function test_parent_cannot_mark_invoice_paid_or_refunded_directly(): void
{
$routes = $this->apiRoutesContainingAny(['invoice', 'payment', 'refund', 'transaction']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'invoice_id' => $this->ids['invoiceId'],
'status' => 'paid',
'paid' => true,
'refunded' => true,
'amount' => 0.01,
];
$response = $this->requestAs($this->parent, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertNotContains($response->getStatusCode(), [200, 201], 'Parent should not directly mutate finance state through '.$route->uri());
}
}
}
@@ -0,0 +1,68 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13PaymentProviderLedgerReconciliationContractTest extends FullSurfaceE2EContractCase
{
public function test_payment_provider_payloads_do_not_bypass_ledger_validation(): void
{
$routes = $this->apiRoutesContainingAny(['paypal', 'stripe', 'payment', 'transaction', 'ledger', 'receipt', 'invoice']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'provider' => 'paypal',
'provider_transaction_id' => 'BATCH13-LEDGER-REPLAY',
'transaction_id' => 'BATCH13-LEDGER-REPLAY',
'external_id' => 'BATCH13-LEDGER-REPLAY',
'amount' => '25.00',
'currency' => 'USD',
'status' => 'completed',
'invoice_id' => $this->ids['invoiceId'],
'parent_id' => $this->ids['parentId'],
'student_id' => $this->ids['studentId'],
'metadata' => [
'invoice_id' => $this->ids['invoiceId'],
'amount' => '999999999999.99',
'currency' => 'XXX',
],
];
$response = $this->requestAs($this->admin, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('SQLSTATE', $response->getContent());
$this->assertStringNotContainsString('ledger imbalance', strtolower($response->getContent()));
}
}
public function test_duplicate_provider_reference_is_controlled_across_payment_routes(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['payment', 'transaction', 'paypal', 'stripe']), 0, 25);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'provider_reference' => 'BATCH13-DUPLICATE-PROVIDER-REFERENCE',
'transaction_id' => 'BATCH13-DUPLICATE-PROVIDER-REFERENCE',
'idempotency_key' => 'BATCH13-DUPLICATE-PROVIDER-REFERENCE',
];
$first = $this->requestAs($this->admin, $method, $route->uri(), $payload);
$second = $this->requestAs($this->admin, $method, $route->uri(), $payload);
$this->assertControlled($first, $method, $route->uri());
$this->assertControlled($second, $method, $route->uri());
$this->assertNotContains($second->getStatusCode(), [500, 501, 502, 503]);
}
}
}
@@ -0,0 +1,51 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13ReportCardTranscriptCertificateForgeryContractTest extends FullSurfaceE2EContractCase
{
public function test_document_generation_rejects_forged_finalization_and_signer_payloads(): void
{
$routes = $this->apiRoutesContainingAny(['report-card', 'transcript', 'certificate', 'print', 'badge', 'sticker', 'slip']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'student_id' => $this->ids['studentId'],
'class_section_id' => $this->ids['classSectionId'],
'finalized' => true,
'locked' => true,
'approved_by' => $this->parent->id,
'principal_signature' => '<script>forge()</script>',
'certificate_number' => '../CERT-FORGED',
'issue_date' => '2099-12-31',
];
$response = $this->requestAs($this->teacher, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('<script>forge()</script>', $response->getContent());
}
}
public function test_students_and_parents_cannot_generate_admin_documents_for_other_students(): void
{
$routes = $this->apiRoutesContainingAny(['certificate', 'transcript', 'badge', 'report-card', 'print']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$payload = ['student_id' => 99999999, 'all_students' => true, 'school_year' => 'all'];
foreach ([$this->parent, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('private_key', strtolower($response->getContent()));
}
}
}
}
@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13RolePermissionMutationRaceContractTest extends FullSurfaceE2EContractCase
{
public function test_permission_mutations_reject_wildcards_and_recursive_assignments(): void
{
$routes = $this->apiRoutesContainingAny(['role', 'permission', 'acl', 'access']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'role_id' => $this->ids['roleId'],
'permissions' => ['*', 'admin.*', '../permissions/delete'],
'permission_ids' => [$this->ids['permissionId'], 99999999, -1],
'inherits_from' => $this->ids['roleId'],
'is_super_admin' => true,
];
$response = $this->requestAs($this->admin, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('SQLSTATE', $response->getContent());
}
}
public function test_non_admin_cannot_gain_permission_by_replaying_role_payload(): void
{
$routes = $this->apiRoutesContainingAny(['role', 'permission', 'users']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
foreach ([$this->teacher, $this->parent, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, $method, $route->uri(), [
'role' => 'administrator',
'role_id' => $this->ids['roleId'],
'permissions' => ['*'],
'approved_by' => $this->admin->id,
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertNotContains($response->getStatusCode(), [200, 201]);
}
}
}
}
@@ -0,0 +1,49 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13StaffHrPayrollBoundaryContractTest extends FullSurfaceE2EContractCase
{
public function test_staff_and_payroll_routes_redact_sensitive_personnel_fields(): void
{
$routes = $this->apiRoutesContainingAny(['staff', 'employee', 'payroll', 'salary', 'hr', 'reimbursement', 'expense']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->admin, $method, $route->uri(), $this->payloadFor($method, $route->uri()));
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
$this->assertStringNotContainsString('ssn', $body);
$this->assertStringNotContainsString('social_security', $body);
$this->assertStringNotContainsString('bank_account', $body);
$this->assertStringNotContainsString('routing_number', $body);
}
}
public function test_low_privilege_users_cannot_mutate_staff_or_payroll_routes(): void
{
$routes = $this->apiRoutesContainingAny(['staff', 'employee', 'payroll', 'salary', 'hr']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'salary' => 999999,
'hourly_rate' => 999,
'role' => 'administrator',
];
foreach ([$this->teacher, $this->parent, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertNotContains($response->getStatusCode(), [200, 201]);
}
}
}
}
@@ -0,0 +1,58 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13StudentEnrollmentLifecycleIntegrityContractTest extends FullSurfaceE2EContractCase
{
public function test_enrollment_routes_reject_cross_family_and_cross_class_payloads_cleanly(): void
{
$routes = $this->apiRoutesContainingAny(['enroll', 'withdraw', 'registration', 'student', 'class-section', 'assignment']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'student_id' => $this->ids['studentId'],
'students' => [$this->ids['studentId'], 99999999],
'parent_id' => 99999999,
'family_id' => 99999999,
'class_section_id' => 99999999,
'previous_class_section_id' => $this->ids['classSectionId'],
'effective_date' => '1900-01-01',
'reason' => 'Batch 13 enrollment integrity probe',
];
$response = $this->requestAs($this->admin, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('Integrity constraint violation', $response->getContent());
}
}
public function test_parent_cannot_self_enroll_or_move_student_into_arbitrary_class(): void
{
$routes = $this->apiRoutesContainingAny(['enroll', 'withdraw', 'class-section', 'student']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'student_id' => $this->ids['studentId'],
'class_section_id' => 99999999,
'force' => true,
'approved_by' => $this->admin->id,
];
$response = $this->requestAs($this->parent, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertNotContains($response->getStatusCode(), [200, 201], 'Parent should not self-enroll or arbitrarily move students through '.$route->uri());
}
}
}
@@ -0,0 +1,54 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch13TeacherRosterAndAssignmentBoundaryContractTest extends FullSurfaceE2EContractCase
{
public function test_teacher_roster_routes_ignore_forced_teacher_and_class_scope_overrides(): void
{
$routes = $this->apiRoutesContainingAny(['teacher', 'roster', 'class-progress', 'attendance', 'scores', 'homework', 'quiz']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$payload = $this->payloadFor($method, $route->uri()) + [
'teacher_id' => $this->admin->id,
'class_section_id' => 99999999,
'student_id' => 99999999,
'include_unassigned' => true,
'all_classes' => true,
'bypass_assignment' => true,
];
$response = $this->requestAs($this->teacher, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('password', strtolower($response->getContent()));
$this->assertStringNotContainsString('remember_token', strtolower($response->getContent()));
}
}
public function test_unassigned_teacher_mutations_are_denied_or_validated(): void
{
$routes = $this->apiRoutesContainingAny(['attendance', 'scores', 'homework', 'quiz', 'class-progress', 'report-card']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'class_section_id' => 99999999,
'student_id' => $this->ids['studentId'],
'teacher_id' => $this->teacher->id,
'status' => 'finalized',
'score' => 100,
];
$response = $this->requestAs($this->teacher, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertNotContains($response->getStatusCode(), [500, 501, 502, 503]);
}
}
}
@@ -0,0 +1,32 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14AcademicPromotionRollbackIntegrityContractTest extends FullSurfaceE2EContractCase
{
public function test_promotion_and_school_year_routes_reject_unsafe_rollback_payloads(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['promotion', 'promote', 'school-year', 'semester', 'close', 'reopen', 'rollback']), 0, 45) as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'from_school_year_id' => $this->ids['schoolYearId'], 'to_school_year_id' => $this->ids['schoolYearId'], 'target_school_year_id' => 99999999, 'rollback' => true, 'force' => true, 'delete_existing_records' => true, 'students' => [$this->ids['studentId'], 99999999], 'class_section_id' => 99999999, 'effective_date' => '1900-01-01',
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('sqlstate', strtolower($response->getContent()));
}
}
public function test_low_privilege_actors_cannot_close_reopen_or_promote_school_years(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['promotion', 'school-year', 'close', 'reopen']), 0, 30) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { continue; }
foreach ([$this->teacher, $this->parent, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, $method, $route->uri(), ['force' => true, 'confirmed' => true, 'school_year_id' => $this->ids['schoolYearId']]);
$this->assertStatusIn($response, [400, 401, 403, 404, 405, 409, 419, 422], $method . ' ' . $route->uri());
}
}
}
}
@@ -0,0 +1,38 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14AccountEnumerationAndIdentityPrivacyContractTest extends FullSurfaceE2EContractCase
{
public function test_identity_recovery_routes_do_not_reveal_whether_user_exists(): void
{
$routes = $this->apiRoutesContainingAny(['forgot', 'password', 'reset', 'verify', 'verification', 'recovery']);
foreach (array_slice($routes, 0, 30) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) { continue; }
foreach ([$this->admin->email, 'missing-user-' . uniqid() . '@example.test'] as $email) {
$response = $this->json($method, $this->materializePath($route->uri()), ['email' => $email, 'token' => 'invalid-token', 'password' => 'NewPassword123!', 'password_confirmation' => 'NewPassword123!']);
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
$this->assertStringNotContainsString('no user found', $body);
$this->assertStringNotContainsString('email does not exist', $body);
$this->assertStringNotContainsString('unknown email', $body);
}
}
}
public function test_identity_payloads_do_not_leak_internal_auth_fields(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['me', 'profile', 'frontend', 'identity', 'user']), 0, 35) as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()));
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
foreach (['password', 'remember_token', 'two_factor', 'api_token', 'provider_token', 'secret'] as $forbidden) {
$this->assertStringNotContainsString($forbidden, $body, $route->uri() . ' leaked identity internals.');
}
}
}
}
@@ -0,0 +1,30 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14AdminDelegationApprovalWorkflowContractTest extends FullSurfaceE2EContractCase
{
public function test_approval_and_delegation_routes_reject_forged_approval_metadata(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['approve', 'approval', 'delegate', 'delegation', 'authorize', 'permission', 'role', 'impersonate']), 0, 50) as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + ['approved' => true, 'approved_by' => $this->admin->id, 'approved_at' => '2000-01-01 00:00:00', 'delegated_by' => $this->admin->id, 'delegated_to' => $this->parent->id, 'permissions' => ['*'], 'role' => 'administrator', 'expires_at' => '2099-01-01 00:00:00']);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('sqlstate', strtolower($response->getContent()));
}
}
public function test_low_privilege_users_cannot_self_approve_or_delegate_privileges(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['approve', 'approval', 'delegate', 'permission', 'role']), 0, 35) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { continue; }
foreach ([$this->teacher, $this->parent, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, $method, $route->uri(), ['approved' => true, 'permissions' => ['*'], 'role' => 'administrator', 'force' => true]);
$this->assertStatusIn($response, [400, 401, 403, 404, 405, 409, 419, 422], $method . ' ' . $route->uri());
}
}
}
}
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14ApiConsumerContractCompatibilityTest extends FullSurfaceE2EContractCase
{
public function test_core_identity_contracts_remain_compatible_for_web_mobile_and_api_clients(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['login', 'auth/me', 'me', 'frontend', 'profile']), 0, 25) as $route) {
$method = $this->primaryMethod($route);
foreach ([['Accept' => 'application/json', 'X-Client' => 'web'], ['Accept' => 'application/json', 'X-Client' => 'mobile', 'X-App-Version' => '1.0.0'], ['Accept' => 'application/vnd.alrahma.v1+json', 'X-Client' => 'api-consumer']] as $headers) {
$response = $this->withHeaders($headers)->actingAs($this->actorFor($route->uri()), 'api')->json($method, $this->materializePath($route->uri()), $this->payloadFor($method, $route->uri()));
$this->assertControlled($response, $method, $route->uri());
$this->assertJsonResponseWhenSuccessful($response, $method . ' ' . $route->uri());
if ($response->isSuccessful()) { $this->assertStringNotContainsString('undefined index', strtolower($response->getContent())); }
}
}
}
public function test_successful_login_like_responses_keep_token_and_user_contract_when_present(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['login', 'auth/login']), 0, 10) as $route) {
$method = $this->primaryMethod($route);
if ($method !== 'POST') { continue; }
$response = $this->json($method, $this->materializePath($route->uri()), ['email' => $this->admin->email, 'password' => 'password']);
$this->assertControlled($response, $method, $route->uri());
if ($response->isSuccessful() && $this->isJsonString($response->getContent())) {
$data = $response->json();
$this->assertTrue(isset($data['token']) || isset($data['access_token']), $route->uri() . ' should expose token/access_token when login succeeds.');
$this->assertArrayHasKey('user', $data, $route->uri() . ' should expose user when login succeeds.');
}
}
}
}
@@ -0,0 +1,32 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14AttendanceDeviceAndKioskAbuseContractTest extends FullSurfaceE2EContractCase
{
public function test_kiosk_scanner_and_attendance_routes_reject_forged_device_identity(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['attendance', 'scanner', 'scan', 'kiosk', 'badge', 'dismissal', 'late']), 0, 55) as $route) {
$method = $this->primaryMethod($route);
$response = $this->withHeaders(['X-Device-Id' => 'trusted-front-desk-tablet', 'X-Kiosk-Mode' => 'true', 'X-Scanner-Token' => 'forged-scanner-token', 'X-Forwarded-For' => '127.0.0.1'])
->actingAs($this->actorFor($route->uri()), 'api')
->json($method, $this->materializePath($route->uri()), $this->payloadFor($method, $route->uri()) + ['badge_token' => 'forged-badge-token', 'device_id' => 'trusted-front-desk-tablet', 'scan_time' => '2099-01-01 00:00:00', 'status' => 'present', 'override' => true]);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('sqlstate', strtolower($response->getContent()));
}
}
public function test_student_and_parent_cannot_self_mark_attendance_through_device_payloads(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['attendance', 'scan', 'dismissal', 'late']), 0, 30) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) { continue; }
foreach ([$this->studentUser, $this->parent] as $actor) {
$response = $this->requestAs($actor, $method, $route->uri(), ['student_id' => $this->ids['studentId'], 'status' => 'present', 'verified_by_device' => true, 'override_reason' => 'parent approved']);
$this->assertStatusIn($response, [400, 401, 403, 404, 405, 409, 419, 422], $method . ' ' . $route->uri());
}
}
}
}
@@ -0,0 +1,32 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14AuditTrailTamperResistanceContractTest extends FullSurfaceE2EContractCase
{
public function test_audit_and_log_routes_reject_client_supplied_actor_and_timestamp_fields(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['audit', 'log', 'activity', 'history', 'trace']), 0, 35) as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'created_by' => $this->admin->id, 'updated_by' => $this->admin->id, 'deleted_by' => $this->admin->id, 'actor_id' => $this->admin->id, 'ip_address' => '127.0.0.1', 'user_agent' => 'forged-user-agent', 'created_at' => '1999-01-01 00:00:00', 'updated_at' => '1999-01-01 00:00:00', 'deleted_at' => '1999-01-01 00:00:00',
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('sqlstate', strtolower($response->getContent()));
}
}
public function test_non_admin_users_cannot_clear_or_rewrite_audit_history(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['audit', 'log', 'history', 'activity']), 0, 25) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { continue; }
foreach ([$this->parent, $this->teacher, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, $method, $route->uri(), ['action' => 'clear', 'purge' => true, 'rewrite' => true]);
$this->assertStatusIn($response, [400, 401, 403, 404, 405, 409, 419, 422], $method . ' ' . $route->uri());
}
}
}
}
@@ -0,0 +1,37 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14BulkAssignmentCollisionContractTest extends FullSurfaceE2EContractCase
{
public function test_bulk_assignment_routes_handle_duplicate_and_conflicting_ids_cleanly(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['assign', 'assignment', 'enroll', 'bulk', 'batch', 'class', 'teacher', 'student']), 0, 50) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) { continue; }
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'student_ids' => [$this->ids['studentId'], $this->ids['studentId'], 99999999], 'teacher_ids' => [$this->teacher->id, $this->teacher->id, $this->parent->id],
'class_section_ids' => [$this->ids['classSectionId'], $this->ids['classSectionId'], 99999999], 'parent_ids' => [$this->ids['parentId'], $this->ids['parentId'], 99999999], 'force' => true, 'overwrite' => true,
]);
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
$this->assertStringNotContainsString('duplicate entry', $body);
$this->assertStringNotContainsString('foreign key constraint', $body);
$this->assertStringNotContainsString('sqlstate', $body);
}
}
public function test_parent_and_student_cannot_force_bulk_assignment_scope(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['assign', 'bulk', 'batch', 'class-sections', 'teacher-class', 'enroll']), 0, 30) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { continue; }
foreach ([$this->parent, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, $method, $route->uri(), ['student_ids' => [$this->ids['studentId']], 'teacher_ids' => [$this->teacher->id], 'class_section_ids' => [$this->ids['classSectionId']], 'all' => true, 'force' => true]);
$this->assertStatusIn($response, [400, 401, 403, 404, 405, 409, 419, 422], $method . ' ' . $route->uri());
}
}
}
}
@@ -0,0 +1,38 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14CrossModuleInvariantRiskRegisterTest extends FullSurfaceE2EContractCase
{
public function test_batch14_route_families_have_cross_module_risk_coverage(): void
{
$riskFamilies = [
'session-cookie-boundary' => ['auth', 'login', 'frontend'],
'identity-privacy' => ['password', 'verify', 'profile', 'me'],
'bulk-assignment' => ['assign', 'bulk', 'batch', 'class'],
'print-export-tamper' => ['print', 'export', 'download', 'certificate'],
'audit-tamper' => ['audit', 'log', 'activity'],
'finance-reconcile' => ['finance', 'payment', 'refund', 'ledger'],
'academic-rollback' => ['promotion', 'school-year', 'reopen'],
'kiosk-device' => ['attendance', 'scanner', 'badge'],
'notification-template' => ['notification', 'template', 'broadcast'],
'storage-signed-url' => ['file', 'media', 'signed', 'upload'],
];
foreach ($riskFamilies as $name => $needles) {
$this->assertNotEmpty($this->apiRoutesContainingAny($needles), 'Batch 14 expected route coverage for ' . $name);
}
}
public function test_batch14_high_risk_mutation_families_exist(): void
{
foreach (['users', 'students', 'attendance', 'finance', 'payment', 'roles', 'permissions', 'settings', 'messages', 'inventory'] as $family) {
$mutations = array_filter($this->apiRoutesContainingAny([$family]), function ($route): bool {
return in_array($this->primaryMethod($route), ['POST', 'PUT', 'PATCH', 'DELETE'], true);
});
$this->assertNotEmpty($mutations, 'Batch 14 expected mutating route family: ' . $family);
}
}
}
@@ -0,0 +1,31 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14DataLifecycleArchivePurgeContractTest extends FullSurfaceE2EContractCase
{
public function test_archive_purge_and_retention_routes_require_safe_controlled_requests(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['archive', 'restore', 'purge', 'erase', 'retention', 'delete', 'destroy']), 0, 50) as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + ['ids' => [$this->ids['studentId'], 99999999, '../../.env'], 'hard_delete' => true, 'purge' => true, 'cascade' => true, 'include_audit_logs' => true, 'reason' => '', 'confirmed' => false]);
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
$this->assertStringNotContainsString('sqlstate', $body); $this->assertStringNotContainsString('foreign key constraint', $body);
}
}
public function test_non_admin_users_cannot_purge_or_restore_global_records(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['purge', 'erase', 'restore', 'archive', 'delete']), 0, 35) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { continue; }
foreach ([$this->teacher, $this->parent, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, $method, $route->uri(), ['ids' => [$this->ids['studentId']], 'hard_delete' => true, 'purge' => true, 'global' => true]);
$this->assertStatusIn($response, [400, 401, 403, 404, 405, 409, 419, 422], $method . ' ' . $route->uri());
}
}
}
}
@@ -0,0 +1,31 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14FinanceReconciliationAndRefundAbuseContractTest extends FullSurfaceE2EContractCase
{
public function test_finance_reconciliation_routes_reject_mismatched_invoice_parent_and_payment_fields(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['finance', 'invoice', 'payment', 'refund', 'transaction', 'ledger', 'reconcile']), 0, 55) as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'invoice_id' => $this->ids['invoiceId'], 'parent_id' => 99999999, 'student_id' => 99999999, 'payment_id' => 99999999, 'refund_id' => 99999999, 'transaction_id' => 'provider-' . uniqid(), 'provider_transaction_id' => 'provider-' . uniqid(), 'amount' => -99999.99, 'net_amount' => -99999.99, 'fee_amount' => -99999.99, 'currency' => 'XXX', 'reconciled' => true, 'force_posted' => true,
]);
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
$this->assertStringNotContainsString('sqlstate', $body); $this->assertStringNotContainsString('duplicate entry', $body); $this->assertStringNotContainsString('foreign key constraint', $body);
}
}
public function test_parent_cannot_self_refund_or_self_reconcile(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['refund', 'reconcile', 'ledger', 'payment']), 0, 35) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { continue; }
$response = $this->requestAs($this->parent, $method, $route->uri(), ['invoice_id' => $this->ids['invoiceId'], 'amount' => 100.00, 'approved' => true, 'reconciled' => true, 'refund_now' => true]);
$this->assertStatusIn($response, [400, 401, 403, 404, 405, 409, 419, 422], $method . ' ' . $route->uri());
}
}
}
@@ -0,0 +1,34 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14NotificationTemplateInjectionContractTest extends FullSurfaceE2EContractCase
{
public function test_notification_and_template_routes_reject_template_injection_payloads(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['notification', 'notify', 'message', 'email', 'whatsapp', 'template', 'broadcast']), 0, 55) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) { continue; }
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'subject' => '{{ config("app.key") }}', 'message' => '<script>alert(document.cookie)</script>{{ $user->password }}', 'body' => '${jndi:ldap://127.0.0.1/a}', 'template' => '@php echo env("APP_KEY"); @endphp', 'channel' => 'all', 'recipients' => ['*'], 'phone' => "+15551234567\r\nBcc: attacker@example.test", 'email' => "victim@example.test\r\nBcc: attacker@example.test",
]);
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
$this->assertStringNotContainsString('app_key', $body); $this->assertStringNotContainsString('document.cookie', $body); $this->assertStringNotContainsString('jndi:ldap', $body);
}
}
public function test_non_admin_users_cannot_broadcast_by_template_override(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['broadcast', 'notification', 'email', 'whatsapp']), 0, 30) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) { continue; }
foreach ([$this->parent, $this->teacher, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, $method, $route->uri(), ['channel' => 'all', 'recipients' => ['all_school'], 'template_id' => 1, 'force_send' => true]);
$this->assertStatusIn($response, [400, 401, 403, 404, 405, 409, 419, 422], $method . ' ' . $route->uri());
}
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14PrintExportTamperingContractTest extends FullSurfaceE2EContractCase
{
public function test_print_export_and_document_generation_routes_reject_tampered_scope_fields(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['print', 'export', 'download', 'certificate', 'badge', 'receipt', 'transcript', 'report-card']), 0, 50) as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'parent_id' => 99999999, 'class_section_id' => 99999999, 'include_private_notes' => true, 'include_deleted' => true, 'include_financials' => true, 'signed_by' => 'Forged Principal', 'signature' => 'data:image/png;base64,forged', 'format' => '../../storage/logs/laravel.log',
]);
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
foreach (['app_key', 'laravel.log', 'password', 'remember_token', 'sqlstate'] as $forbidden) { $this->assertStringNotContainsString($forbidden, $body); }
}
}
public function test_low_privilege_users_cannot_generate_full_school_exports(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['export', 'download', 'print', 'report', 'certificate']), 0, 35) as $route) {
$method = $this->primaryMethod($route);
foreach ([$this->parent, $this->teacher, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, $method, $route->uri(), ['scope' => 'all_school', 'all_students' => true, 'include_finance' => true, 'include_guardians' => true, 'format' => 'csv']);
$this->assertStatusIn($response, [200, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], $method . ' ' . $route->uri());
$this->assertStringNotContainsString('remember_token', strtolower($response->getContent()));
}
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14RegressionMegaSweepTest extends FullSurfaceE2EContractCase
{
public function test_batch14_mega_sweep_keeps_new_risk_payloads_under_controlled_failure(): void
{
$families = [
['auth', 'login', 'password', 'verify'], ['users', 'roles', 'permissions', 'profile'], ['students', 'parents', 'family', 'guardian'],
['class', 'teacher', 'assignment', 'promotion'], ['attendance', 'scanner', 'badge', 'dismissal'], ['scores', 'grade', 'report-card', 'certificate'],
['finance', 'invoice', 'payment', 'refund', 'ledger'], ['inventory', 'supplier', 'procurement', 'stock'], ['message', 'notification', 'email', 'whatsapp', 'template'],
['file', 'media', 'upload', 'download', 'export', 'import'], ['audit', 'log', 'activity', 'settings'],
];
foreach ($families as $needles) {
foreach (array_slice($this->apiRoutesContainingAny($needles), 0, 18) as $route) {
$method = $this->primaryMethod($route);
$payload = $this->payloadFor($method, $route->uri()) + [
'id' => '../../.env', 'student_id' => ['not', 'an', 'id'], 'parent_id' => 99999999, 'amount' => '-999999999.999', 'status' => '<script>bad()</script>',
'path' => '../../storage/logs/laravel.log', 'signature' => 'forged', 'approved_by' => $this->admin->id, 'owner_type' => 'App\\Models\\User', 'owner_id' => $this->admin->id,
'include' => '*,password,remember_token,audit_logs', 'scope' => 'global', 'force' => true, 'hard_delete' => true,
];
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
foreach (['sqlstate', 'app_key', 'db_password', 'remember_token', 'stack trace', 'laravel.log'] as $forbidden) { $this->assertStringNotContainsString($forbidden, $body); }
}
}
}
}
@@ -0,0 +1,31 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14RelationshipOwnershipMatrixExpansionTest extends FullSurfaceE2EContractCase
{
public function test_relationship_owner_override_fields_are_not_trusted_across_route_families(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['student', 'parent', 'family', 'invoice', 'attendance', 'score', 'guardian', 'authorized']), 0, 60) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { continue; }
foreach ([$this->parent, $this->teacher] as $actor) {
$response = $this->requestAs($actor, $method, $route->uri(), $this->payloadFor($method, $route->uri()) + ['student_ids' => [$this->ids['studentId'], 99999999], 'parent_id' => 99999999, 'family_id' => 99999999, 'guardian_id' => 99999999, 'owner_id' => $this->admin->id, 'owner_type' => 'App\\Models\\User', 'created_by' => $this->admin->id, 'scope' => 'global']);
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent()); $this->assertStringNotContainsString('sqlstate', $body); $this->assertStringNotContainsString('remember_token', $body);
}
}
}
public function test_student_user_cannot_read_or_write_other_relationship_graphs(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['parent', 'family', 'guardian', 'invoice', 'attendance', 'report-card']), 0, 40) as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->studentUser, $method, $route->uri(), ['student_id' => 99999999, 'parent_id' => 99999999, 'family_id' => 99999999, 'include' => 'guardians,invoices,attendance,notes']);
$this->assertStatusIn($response, [200, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], $method . ' ' . $route->uri());
$this->assertStringNotContainsString('remember_token', strtolower($response->getContent()));
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14SessionCookieCsrfBoundaryContractTest extends FullSurfaceE2EContractCase
{
public function test_api_auth_routes_do_not_depend_on_browser_session_cookies(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['auth', 'login', 'logout', 'me', 'profile', 'frontend']), 0, 40) as $route) {
$method = $this->primaryMethod($route);
$response = $this->withCookie('laravel_session', 'attacker-controlled-session')
->withHeader('X-XSRF-TOKEN', 'not-a-real-xsrf-token')
->actingAs($this->actorFor($route->uri()), 'api')
->json($method, $this->materializePath($route->uri()), $this->payloadFor($method, $route->uri()));
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('csrf token mismatch', strtolower($response->getContent()));
}
}
public function test_browser_only_session_headers_do_not_grant_api_privileges(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['admin', 'users', 'roles', 'permissions', 'finance', 'inventory', 'attendance']), 0, 30) as $route) {
$method = $this->primaryMethod($route);
$response = $this->withCookie('laravel_session', 'fake-admin-browser-session')
->withHeaders(['X-Requested-With' => 'XMLHttpRequest', 'X-CSRF-TOKEN' => 'fake-csrf', 'X-User-Id' => (string) $this->admin->id])
->json($method, $this->materializePath($route->uri()), $this->payloadFor($method, $route->uri()));
$this->assertStatusIn($response, [401, 403, 404, 405, 419, 422], $method . ' ' . $route->uri());
$this->assertStringNotContainsString('sqlstate', strtolower($response->getContent()));
}
}
}
@@ -0,0 +1,29 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch14StoragePathAndSignedUrlContractTest extends FullSurfaceE2EContractCase
{
public function test_file_and_signed_url_routes_reject_path_and_disk_overrides(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['file', 'media', 'upload', 'download', 'export', 'import', 'attachment', 'document', 'signed']), 0, 55) as $route) {
$method = $this->primaryMethod($route);
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + ['path' => '../../.env', 'filename' => '../../storage/logs/laravel.log', 'disk' => 'local', 'storage_disk' => 's3://private-bucket', 'url' => 'file:///etc/passwd', 'expires' => '2099-01-01', 'signature' => 'forged-signature', 'mime_type' => 'text/x-php']);
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
foreach (['app_key', 'db_password', 'laravel.log', '/etc/passwd', 'begin rsa private key'] as $forbidden) { $this->assertStringNotContainsString($forbidden, $body); }
}
}
public function test_public_file_routes_do_not_honor_expired_or_forged_signature_parameters(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['download', 'signed', 'public', 'media', 'file']), 0, 35) as $route) {
$method = $this->primaryMethod($route);
$response = $this->json($method, $this->materializePath($route->uri()) . '?signature=forged&expires=1&path=../../.env');
$this->assertStatusIn($response, [200, 204, 302, 400, 401, 403, 404, 405, 410, 419, 422], $method . ' ' . $route->uri());
$this->assertStringNotContainsString('app_key', strtolower($response->getContent()));
}
}
}
@@ -0,0 +1,32 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15AdminImpersonationSessionBoundaryContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_impersonation_and_acting_as_routes_require_real_admin_context_not_payload_claims(): void
{
$routes = $this->apiRoutesContainingAny(['impersonate', 'acting-as', 'act-as', 'switch-user', 'delegate', 'behalf']);
foreach (array_slice($routes, 0, 30) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'impersonator_id' => $this->admin->id,
'target_user_id' => $this->admin->id,
'acting_as' => 'administrator',
'reason' => 'Batch 15 impersonation boundary probe',
'approved_by' => $this->admin->id,
'expires_at' => '2099-01-01',
];
$parentResponse = $this->requestAs($this->parent, $method, $uri, $payload);
$this->assertControlled($parentResponse, $method, $uri);
$this->assertStringNotContainsString('access_token', strtolower($parentResponse->getContent()));
}
}
}
@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15AuthTokenRotationAndRevocationContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_token_rotation_revocation_and_identity_routes_do_not_accept_stale_or_forged_tokens(): void
{
$routes = $this->apiRoutesContainingAny(['auth', 'login', 'logout', 'refresh', 'token', 'me', 'profile', 'session']);
foreach (array_slice($routes, 0, 35) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'token' => 'forged.batch15.token',
'refresh_token' => 'stale-refresh-token',
'access_token' => 'Bearer forged',
'expires_in' => -1,
'user_id' => $this->admin->id,
'remember' => true,
];
$response = $this->requestAs($this->actorFor($uri), $method, $uri, $payload);
$this->assertControlled($response, $method, $uri);
$body = strtolower($response->getContent());
foreach (['plain_password', 'remember_token', 'oauth_secret', 'personal_access_token', 'refresh_token_secret'] as $forbidden) {
$this->assertStringNotContainsString($forbidden, $body);
}
}
}
public function test_batch15_repeat_logout_and_refresh_replay_are_controlled(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['logout', 'refresh', 'token']), 0, 20) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
foreach (['first-replay-key', 'first-replay-key'] as $key) {
$response = $this->withHeader('Idempotency-Key', $key)
->requestAs($this->admin, $method, $uri, $this->payloadFor($method, $uri));
$this->assertControlled($response, $method, $uri);
}
}
}
}
@@ -0,0 +1,34 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15BackgroundCommandAndSchedulerContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_command_scheduler_and_job_trigger_routes_are_admin_only_and_idempotent(): void
{
$routes = $this->apiRoutesContainingAny(['command', 'schedule', 'scheduler', 'cron', 'job', 'queue', 'sync', 'recalculate', 'cleanup', 'send']);
foreach (array_slice($routes, 0, 40) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'command' => 'migrate:fresh --seed',
'force' => true,
'run_now' => true,
'target' => 'all',
'client_request_id' => 'batch15-command-replay',
];
$teacherResponse = $this->requestAs($this->teacher, $method, $uri, $payload);
$this->assertControlled($teacherResponse, $method, $uri);
$this->assertStringNotContainsString('migrate:fresh', strtolower($teacherResponse->getContent()));
$adminResponse = $this->requestAs($this->admin, $method, $uri, $payload);
$this->assertControlled($adminResponse, $method, $uri);
}
}
}
@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15ConsentPrivacyAndDataSubjectRightsContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_privacy_consent_erasure_and_export_routes_are_admin_or_owner_scoped(): void
{
$routes = $this->apiRoutesContainingAny(['privacy', 'consent', 'erase', 'erasure', 'delete-account', 'export-data', 'data-subject', 'retention']);
foreach (array_slice($routes, 0, 35) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'user_id' => $this->admin->id,
'parent_id' => $this->ids['parentId'],
'student_id' => 99999999,
'scope' => 'all_users',
'include_deleted' => true,
'include_sensitive' => true,
'reason' => 'Batch 15 privacy boundary probe',
];
$parentResponse = $this->requestAs($this->parent, $method, $uri, $payload);
$this->assertControlled($parentResponse, $method, $uri);
$this->assertStringNotContainsString('password', strtolower($parentResponse->getContent()));
$this->assertStringNotContainsString('remember_token', strtolower($parentResponse->getContent()));
}
}
public function test_batch15_student_actor_cannot_request_family_wide_or_school_wide_privacy_exports(): void
{
foreach (array_slice($this->apiRoutesContainingAny(['export', 'privacy', 'download', 'report']), 0, 30) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$response = $this->requestAs($this->studentUser, $method, $uri, $this->payloadFor($method, $uri) + [
'scope' => 'school', 'family_id' => 1, 'student_ids' => '*', 'include_finance' => true,
]);
$this->assertControlled($response, $method, $uri);
$this->assertStringNotContainsString('db_password', strtolower($response->getContent()));
}
}
}
@@ -0,0 +1,35 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15CrossSchoolIdentityCollisionContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_duplicate_external_identifiers_and_cross_school_aliases_fail_cleanly(): void
{
$routes = $this->apiRoutesContainingAny(['students', 'users', 'parents', 'family', 'guardian', 'enrollment', 'registration']);
foreach (array_slice($routes, 0, 35) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'external_id' => 'SHARED-EXTERNAL-ID',
'student_number' => 'SHARED-STUDENT-NUMBER',
'email' => $this->admin->email,
'phone' => '+15550000000',
'school_id' => 99999999,
'tenant_id' => 99999999,
];
$response = $this->requestAs($this->admin, $method, $route->uri(), $payload);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('duplicate entry', strtolower($response->getContent()));
}
}
}
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15DataResidencyBackupAndRestoreContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_backup_restore_residency_and_snapshot_routes_do_not_expose_or_mutate_for_low_privilege_users(): void
{
$routes = $this->apiRoutesContainingAny(['backup', 'restore', 'snapshot', 'residency', 'tenant', 'database', 'dump', 'archive']);
foreach (array_slice($routes, 0, 30) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'region' => 'external-untrusted-region',
'path' => '../../database/database.sqlite',
'include_secrets' => true,
'restore_from' => 's3://evil-bucket/snapshot.sql',
'truncate_before_restore' => true,
];
foreach ([$this->parent, $this->teacher, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, $method, $uri, $payload);
$this->assertControlled($response, $method, $uri);
$body = strtolower($response->getContent());
foreach (['app_key', 'db_password', 'database.sqlite', 'backup.sql', 'aws_secret'] as $forbidden) {
$this->assertStringNotContainsString($forbidden, $body);
}
}
}
}
}
@@ -0,0 +1,32 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15DomainEventOutboxAndNotificationConsistencyContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_event_outbox_and_notification_trigger_routes_reject_forged_event_metadata(): void
{
$routes = $this->apiRoutesContainingAny(['event', 'outbox', 'notification', 'broadcast', 'message', 'email', 'whatsapp', 'webhook']);
foreach (array_slice($routes, 0, 45) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'event_id' => 'evt_batch15_replay',
'event_type' => 'UserPromotedToAdministrator',
'aggregate_type' => 'school',
'aggregate_id' => '*',
'actor_id' => $this->admin->id,
'delivered_at' => '2099-01-01T00:00:00Z',
'signature' => 'forged-signature',
];
$response = $this->requestAs($this->teacher, $method, $uri, $payload);
$this->assertControlled($response, $method, $uri);
}
}
}
@@ -0,0 +1,32 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15FeatureFlagExperimentAndRolloutContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_feature_flag_and_rollout_routes_reject_low_privilege_override_payloads(): void
{
$routes = $this->apiRoutesContainingAny(['feature', 'flag', 'experiment', 'rollout', 'settings', 'configuration']);
foreach (array_slice($routes, 0, 35) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'feature' => 'all_admin_features',
'enabled' => true,
'rollout' => 100,
'audience' => 'all',
'actor_id' => $this->parent->id,
'bypass_permissions' => true,
'expires_at' => '2099-01-01',
];
$response = $this->requestAs($this->parent, $method, $uri, $payload);
$this->assertControlled($response, $method, $uri);
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15FormRequestAuthorizationCoverageContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_mutating_routes_do_not_allow_public_or_low_privilege_form_request_bypass(): void
{
foreach (array_slice($this->apiRoutes(), 0, 120) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'_method' => $method,
'authorize' => true,
'can' => '*',
'role' => 'administrator',
'permissions' => ['*'],
];
$this->actingAs($this->studentUser, 'api');
$response = $this->json($method, $this->materializePath($uri), $payload);
$this->assertControlled($response, $method, $uri);
}
}
}
@@ -0,0 +1,31 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15GlobalSearchAndDirectoryEnumerationContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_global_search_directory_and_lookup_routes_resist_enumeration_inputs(): void
{
$routes = $this->apiRoutesContainingAny(['search', 'lookup', 'directory', 'autocomplete', 'users', 'students', 'parents']);
$queries = ['a', '*', '%', '@', 'admin@example.com', $this->admin->email, ' OR 1=1 --', str_repeat('x', 2048)];
foreach (array_slice($routes, 0, 45) as $route) {
$method = $this->primaryMethod($route);
foreach (array_slice($queries, 0, 4) as $query) {
$response = $this->requestAs($this->studentUser, $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'q' => $query,
'search' => $query,
'email' => $query,
'scope' => 'all',
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('sqlstate', strtolower($response->getContent()));
}
}
}
}
@@ -0,0 +1,35 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15LocalizationContentDirectionAndEncodingContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_rtl_unicode_and_encoding_payloads_remain_controlled_across_text_routes(): void
{
$routes = $this->apiRoutesContainingAny(['message', 'contact', 'support', 'notification', 'student', 'parent', 'settings', 'certificate']);
$text = "اختبار עברית café e\u{0301} \u{202E}evil.pdf 😬";
foreach (array_slice($routes, 0, 45) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$response = $this->withHeader('Accept-Language', 'ar,en;q=0.7')
->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $this->payloadFor($method, $route->uri()) + [
'name' => $text,
'title' => $text,
'message' => $text,
'notes' => $text,
'description' => $text,
]);
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('malformed utf-8', strtolower($response->getContent()));
}
}
}
@@ -0,0 +1,35 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15MetadataFilteringAndSparseFieldsetSecurityContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_sparse_fieldsets_metadata_and_embeds_do_not_reveal_sensitive_associations(): void
{
$routes = $this->apiRoutesContainingAny(['users', 'students', 'parents', 'family', 'finance', 'attendance', 'reports', 'dashboard']);
foreach (array_slice($routes, 0, 55) as $route) {
$method = $this->primaryMethod($route);
if ($method !== 'GET') {
continue;
}
$response = $this->requestAs($this->parent, $method, $route->uri(), [
'fields' => '*,password,remember_token,permissions,roles,audit_logs',
'include' => 'users,roles,permissions,finance.raw_provider_payload,deleted,archived',
'meta' => 'debug,sql,bindings,policies',
'debug' => true,
]);
$this->assertControlled($response, $method, $route->uri());
$body = strtolower($response->getContent());
foreach (['remember_token', 'raw_provider_payload', 'sql', 'bindings', 'password'] as $forbidden) {
$this->assertStringNotContainsString($forbidden, $body);
}
}
}
}
@@ -0,0 +1,35 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15RateLimitAndAbusePatternContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_repeated_sensitive_requests_return_controlled_responses_without_leaking_internals(): void
{
$routes = $this->apiRoutesContainingAny(['login', 'password', 'verify', 'contact', 'support', 'message', 'scanner', 'badge']);
foreach (array_slice($routes, 0, 25) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
for ($attempt = 1; $attempt <= 3; $attempt++) {
$response = $this->withHeader('X-Forwarded-For', '203.0.113.' . $attempt)
->requestAs($this->actorFor($uri), $method, $uri, $this->payloadFor($method, $uri) + [
'email' => 'rate-limit-'.$attempt.'@example.test',
'password' => 'wrong-password',
'message' => str_repeat('abuse ', 200),
]);
$this->assertControlled($response, $method, $uri);
$body = strtolower($response->getContent());
foreach (['too many connections', 'sqlstate', 'stack trace', 'vendor/laravel'] as $forbidden) {
$this->assertStringNotContainsString($forbidden, $body);
}
}
}
}
}
@@ -0,0 +1,47 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15RegressionUltraSweepTest extends FullSurfaceE2EContractCase
{
public function test_batch15_ultra_sweep_keeps_cross_domain_hostile_payloads_under_controlled_responses(): void
{
$families = [
['auth', 'token', 'session', 'profile'], ['privacy', 'consent', 'export', 'erase'], ['students', 'parents', 'family', 'guardian'],
['attendance', 'safety', 'incident', 'scanner'], ['scores', 'grade', 'report-card', 'certificate'], ['finance', 'invoice', 'payment', 'refund', 'ledger'],
['inventory', 'supplier', 'procurement', 'stock'], ['message', 'notification', 'email', 'whatsapp'], ['backup', 'restore', 'job', 'queue', 'settings'],
];
foreach ($families as $needles) {
foreach (array_slice($this->apiRoutesContainingAny($needles), 0, 20) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'tenant_id' => 99999999,
'school_id' => 99999999,
'owner_id' => $this->admin->id,
'owner_type' => 'App\\Models\\User',
'scope' => 'global',
'include' => '*,password,remember_token,secrets,deleted',
'force' => true,
'hard_delete' => true,
'amount' => 'NaN',
'path' => '../../.env',
'signature' => 'forged',
'redirect' => 'https://evil.example.test',
];
$response = $this->requestAs($this->actorFor($uri), $method, $uri, $payload);
$this->assertControlled($response, $method, $uri);
$body = strtolower($response->getContent());
foreach (['app_key', 'db_password', 'remember_token', 'stack trace', 'sqlstate', '.env'] as $forbidden) {
$this->assertStringNotContainsString($forbidden, $body);
}
}
}
}
}
@@ -0,0 +1,38 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15ReportDrilldownAndAggregatePrivacyContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_report_drilldown_filters_do_not_allow_scope_escape_or_sensitive_field_inclusion(): void
{
$routes = $this->apiRoutesContainingAny(['report', 'analytics', 'dashboard', 'summary', 'drilldown', 'export']);
foreach (array_slice($routes, 0, 50) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'drilldown' => true,
'group_by' => 'password',
'include' => 'users.password,parents.ssn,finance.raw_provider_payload',
'student_id' => 99999999,
'parent_id' => 99999999,
'scope' => 'global',
'format' => 'csv',
];
foreach ([$this->parent, $this->teacher] as $actor) {
$response = $this->requestAs($actor, $method, $uri, $payload);
$this->assertControlled($response, $method, $uri);
$body = strtolower($response->getContent());
foreach (['password', 'remember_token', 'provider_secret', 'raw_provider_payload'] as $forbidden) {
$this->assertStringNotContainsString($forbidden, $body);
}
}
}
}
}
@@ -0,0 +1,30 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15RouteParameterPoisoningExpansionTest extends FullSurfaceE2EContractCase
{
public function test_batch15_route_parameters_reject_encoded_path_script_and_type_confusion_payloads_cleanly(): void
{
$poisoned = ['..%2F..%2F.env', '%00', '<svg/onload=alert(1)>', '1 or 1=1', str_repeat('9', 80), '٠١٢٣', 'null', '[]'];
foreach (array_slice($this->apiRoutes(), 0, 80) as $route) {
if (! str_contains($route->uri(), '{')) {
continue;
}
$method = $this->primaryMethod($route);
foreach (array_slice($poisoned, 0, 4) as $value) {
$path = '/' . preg_replace('/\{[^}]+\}/', $value, $route->uri());
$this->actingAs($this->actorFor($route->uri()), 'api');
$response = $this->json($method, $path, $this->payloadFor($method, $route->uri()));
$this->assertControlled($response, $method, $route->uri());
$this->assertStringNotContainsString('laravel.log', strtolower($response->getContent()));
}
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBatch15StudentSafetyIncidentEscalationContractTest extends FullSurfaceE2EContractCase
{
public function test_batch15_safety_incident_escalation_routes_are_not_mutable_by_parents_or_students(): void
{
$routes = $this->apiRoutesContainingAny(['incident', 'violation', 'safety', 'alert', 'flag', 'dismissal', 'late', 'absence', 'attendance']);
foreach (array_slice($routes, 0, 50) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'severity' => 'critical',
'student_id' => $this->ids['studentId'],
'resolved' => true,
'resolved_by' => $this->admin->id,
'notify_parent' => false,
'suppress_alert' => true,
];
foreach ([$this->parent, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, $method, $uri, $payload);
$this->assertControlled($response, $method, $uri);
}
}
}
}
@@ -0,0 +1,81 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route as LaravelRoute;
use Illuminate\Support\Facades\Storage;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBulkImportExportAndPartialFailureExpansionTest extends FullSurfaceE2EContractCase
{
public function test_bulk_routes_handle_mixed_valid_and_invalid_ids_cleanly(): void
{
foreach ($this->bulkRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$response = $this->requestAs($this->actorFor($uri), $method, $uri, [
'ids' => [$this->ids['studentId'], 999999999, null, 'not-an-id'],
'student_ids' => [$this->ids['studentId'], 999999999],
'class_section_id' => $this->ids['classSectionId'],
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
]);
$this->assertControlled($response, $method, $uri);
$this->assertNoServerError($response, "$method $uri mixed bulk payload");
}
}
public function test_import_routes_reject_wrong_file_types_with_validation_not_crashes(): void
{
Storage::fake('local');
foreach ($this->importRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$response = $this->requestAs($this->actorFor($uri), $method, $uri, [
'file' => UploadedFile::fake()->create('not-a-spreadsheet.exe', 4, 'application/x-msdownload'),
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
]);
$this->assertControlled($response, $method, $uri);
$this->assertNoServerError($response, "$method $uri wrong import file type");
}
}
public function test_export_download_routes_do_not_return_mutating_statuses(): void
{
foreach ($this->exportRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$response = $this->requestAs($this->actorFor($uri), $method, $uri, [
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'format' => 'xlsx',
]);
$this->assertStatusIn($response, [200, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], "$method $uri export/download");
$this->assertNoServerError($response, "$method $uri export/download");
}
}
/** @return list<LaravelRoute> */
private function bulkRoutes(): array
{
return array_slice(array_filter($this->apiRoutes(), fn (LaravelRoute $route): bool => preg_match('/bulk|batch|selected|import|assign|sync/i', $route->uri()) === 1), 0, 80);
}
/** @return list<LaravelRoute> */
private function importRoutes(): array
{
return array_slice(array_filter($this->apiRoutes(), fn (LaravelRoute $route): bool => preg_match('/import|upload|attachment|document/i', $route->uri()) === 1 && in_array($this->primaryMethod($route), ['POST', 'PUT', 'PATCH'], true)), 0, 50);
}
/** @return list<LaravelRoute> */
private function exportRoutes(): array
{
return array_slice(array_filter($this->apiRoutes(), fn (LaravelRoute $route): bool => preg_match('/export|download|pdf|print|receipt|certificate|badge/i', $route->uri()) === 1), 0, 80);
}
}
@@ -0,0 +1,85 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiBusinessRuleEdgeCaseContractTest extends FullSurfaceE2EContractCase
{
public function test_school_year_semester_and_class_mismatch_payloads_fail_without_crashing(): void
{
$routes = $this->apiRoutesContainingAny(['students', 'class', 'attendance', 'scores', 'report-card', 'teacher']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'school_year' => '1900-1901',
'semester' => 'Summer-Does-Not-Exist',
'class_section_id' => 999999,
'student_id' => 999999,
];
$response = $this->requestAs($this->actorFor($uri), $method, $uri, $payload);
$this->assertNoServerError($response, "$method $uri mismatched academic context");
$this->assertStatusIn($response, [200, 201, 202, 204, 302, 400, 401, 403, 404, 409, 419, 422], "$method $uri mismatched academic context");
$this->assertStringNotContainsStringIgnoringCase('foreign key constraint', $response->getContent(), $uri . ' should validate academic context before DB failure leaks.');
}
}
public function test_financial_amount_rules_reject_zero_negative_and_precision_abuse_cleanly(): void
{
$routes = $this->apiRoutesContainingAny(['finance', 'payment', 'refund', 'invoice', 'fee', 'installment', 'charge']);
$amounts = [0, -0.01, -999999, '0.0000000000001', '1000000000000000000000000.99'];
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
foreach ($amounts as $amount) {
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri);
$payload['amount'] = $amount;
$payload['total'] = $amount;
$payload['balance'] = $amount;
$response = $this->requestAs($this->admin, $method, $uri, $payload);
$this->assertNoServerError($response, "$method $uri amount $amount");
$this->assertStringNotContainsStringIgnoringCase('decimal', $response->getContent(), $uri . ' should not leak decimal cast internals.');
$this->assertStringNotContainsStringIgnoringCase('SQLSTATE', $response->getContent(), $uri . ' should not leak SQLSTATE for financial edge cases.');
}
}
}
public function test_attendance_status_and_time_edge_cases_are_controlled(): void
{
$routes = $this->apiRoutesContainingAny(['attendance', 'late', 'dismissal', 'absence']);
$statuses = ['present', 'absent', 'late', 'excused', 'early_dismissal', 'not-a-real-status'];
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
foreach ($statuses as $status) {
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'status' => $status,
'arrival_time' => '25:99',
'dismissal_time' => '-01:00',
];
$response = $this->requestAs($this->actorFor($uri), $method, $uri, $payload);
$this->assertNoServerError($response, "$method $uri status $status");
$this->assertControlled($response, $method, $uri);
}
}
}
}
@@ -0,0 +1,42 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiCacheFreshnessAndConditionalRequestContractTest extends FullSurfaceE2EContractCase
{
public function test_private_authenticated_resources_are_not_publicly_cacheable(): void
{
foreach ($this->apiRoutesContainingAny(['profile', 'auth/me', 'dashboard', 'finance', 'messages', 'students', 'parents']) as $route) {
if ($this->primaryMethod($route) !== 'GET') {
continue;
}
$response = $this->requestAs($this->actorFor($route->uri()), 'GET', $route->uri());
$this->assertControlled($response, 'GET', $route->uri());
$cacheControl = strtolower((string) $response->headers->get('Cache-Control'));
$this->assertFalse(
str_contains($cacheControl, 'public') && ! str_contains($cacheControl, 'private'),
"{$route->uri()} should not publicly cache private authenticated data."
);
}
}
public function test_conditional_headers_do_not_bypass_authorization_or_return_wrong_user_data(): void
{
foreach ($this->apiRoutesContainingAny(['profile', 'auth/me', 'dashboard', 'students', 'invoices']) as $route) {
if ($this->primaryMethod($route) !== 'GET') {
continue;
}
$response = $this->withHeaders([
'If-None-Match' => '"fake-etag-from-other-user"',
'If-Modified-Since' => 'Wed, 21 Oct 2015 07:28:00 GMT',
])->getJson($this->materializePath($route->uri()));
$this->assertStatusIn($response, [401, 403, 404, 419], "Conditional unauthenticated request should not bypass {$route->uri()}.");
}
}
}
@@ -0,0 +1,56 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiCalendarScheduleConflictContractTest extends FullSurfaceE2EContractCase
{
public function test_calendar_event_and_school_schedule_routes_handle_conflicts_and_invalid_ranges(): void
{
$routes = $this->apiRoutesContainingAny(['calendar', 'schedule', 'event', 'school-day', 'semester']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'title' => 'E2E Conflict Probe',
'start_date' => '2026-06-20',
'end_date' => '2025-09-01',
'start_time' => '18:00',
'end_time' => '08:00',
'school_year' => self::E2E_PREV_SCHOOL_YEAR,
'recurrence' => 'FREQ=MINUTELY;INTERVAL=0',
];
$response = $this->requestAs($this->admin, $method, $uri, $payload);
$this->assertNoServerError($response, "$method $uri invalid schedule conflict");
$this->assertControlled($response, $method, $uri);
}
}
public function test_calendar_list_routes_tolerate_window_queries_and_do_not_leak_unscoped_private_events(): void
{
$routes = $this->apiRoutesContainingAny(['calendar', 'schedule', 'event']);
foreach ($routes as $route) {
if ($this->primaryMethod($route) !== 'GET') {
continue;
}
$uri = $route->uri();
$query = '?from=1900-01-01&to=2100-12-31&scope=all&include_private=true';
foreach (['parent' => $this->parent, 'teacher' => $this->teacher, 'student' => $this->studentUser] as $label => $actor) {
$response = $this->requestAs($actor, 'GET', $uri . $query);
$this->assertNoServerError($response, "$label GET $uri calendar scope");
$this->assertStatusIn($response, [200, 204, 302, 400, 401, 403, 404, 409, 419, 422], "$label GET $uri calendar scope");
$this->assertStringNotContainsStringIgnoringCase('private_note', $response->getContent(), "$uri should not expose private event notes to $label.");
}
}
}
}
@@ -0,0 +1,71 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Support\Facades\Route;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiClientCompatibilityVersioningContractTest extends FullSurfaceE2EContractCase
{
public function test_publicly_registered_api_routes_remain_versioned_or_explicitly_legacy(): void
{
$allowedUnversioned = [
'api/login', 'api/logout', 'api/user', 'api/register', 'api/forgot-password', 'api/reset-password',
'api/docs', 'api/health', 'api/db-check', 'api/badge-scan', 'api/scanner',
];
foreach ($this->apiRoutes() as $route) {
$uri = $route->uri();
if (str_starts_with($uri, 'api/v1/')) {
$this->assertTrue(true);
continue;
}
$isAllowed = false;
foreach ($allowedUnversioned as $allowed) {
if (str_starts_with($uri, $allowed)) {
$isAllowed = true;
break;
}
}
$this->assertTrue(
$isAllowed,
$uri . ' is an unversioned API route. If this is deliberate legacy support, add it to the explicit allow-list.'
);
}
}
public function test_route_names_are_unique_enough_for_client_generation(): void
{
$seen = [];
foreach (Route::getRoutes()->getRoutes() as $route) {
$name = $route->getName();
if ($name === null || ! str_starts_with($route->uri(), 'api/')) {
continue;
}
$key = $name . '|' . $this->primaryMethod($route);
$this->assertArrayNotHasKey($key, $seen, 'Duplicate API route name/method pair: ' . $key . ' for ' . $route->uri() . ' and ' . ($seen[$key] ?? 'unknown'));
$seen[$key] = $route->uri();
}
}
public function test_legacy_and_current_auth_contracts_return_compatible_failure_shapes(): void
{
$pairs = [
['/api/login', '/api/v1/auth/login'],
['/user/login', '/api/v1/auth/login'],
];
foreach ($pairs as [$legacy, $current]) {
$legacyResponse = $this->postJson($legacy, ['email' => 'bad@example.test', 'password' => 'wrong']);
$currentResponse = $this->postJson($current, ['email' => 'bad@example.test', 'password' => 'wrong']);
$this->assertNoServerError($legacyResponse, $legacy . ' legacy auth failure');
$this->assertNoServerError($currentResponse, $current . ' current auth failure');
$this->assertStatusIn($legacyResponse, [400, 401, 403, 419, 422], $legacy . ' legacy auth failure');
$this->assertStatusIn($currentResponse, [400, 401, 403, 419, 422], $current . ' current auth failure');
}
}
}
@@ -0,0 +1,49 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiConcurrencyAndStaleWriteContractTest extends FullSurfaceE2EContractCase
{
public function test_duplicate_update_payloads_remain_controlled_and_do_not_throw_server_errors(): void
{
foreach ($this->apiRoutesContainingAny(['profile', 'settings', 'attendance', 'inventory', 'payments', 'refunds', 'school-years', 'messages']) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['PUT', 'PATCH', 'POST'], true)) {
continue;
}
$payload = $this->payloadFor($method, $route->uri()) + [
'client_request_id' => 'e2e-concurrency-' . md5($route->uri()),
'updated_at' => '2000-01-01T00:00:00Z',
];
$first = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $payload);
$second = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $payload);
$this->assertControlled($first, $method, $route->uri());
$this->assertControlled($second, $method, $route->uri());
$this->assertStringNotContainsString('deadlock', strtolower($second->getContent()));
$this->assertStringNotContainsString('duplicate entry', strtolower($second->getContent()));
}
}
public function test_stale_if_match_headers_do_not_force_successful_mutations(): void
{
foreach ($this->apiRoutesContainingAny(['students', 'parents', 'inventory', 'payments', 'attendance', 'settings']) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$response = $this->actingAs($this->actorFor($route->uri()), 'api')
->withHeader('If-Match', '"stale-version-that-should-not-win"')
->json($method, $this->materializePath($route->uri()), $this->payloadFor($method, $route->uri()));
$this->assertStatusIn($response, [200, 202, 204, 400, 403, 404, 409, 412, 422], "{$method} {$route->uri()} stale write");
}
}
}
@@ -0,0 +1,61 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiContentNegotiationAndMediaTypeContractTest extends FullSurfaceE2EContractCase
{
/** @test */
public function json_accept_header_receives_controlled_api_responses_across_major_domains(): void
{
$routes = array_slice($this->apiRoutesContainingAny([
'auth', 'users', 'students', 'parents', 'teacher', 'attendance', 'finance', 'inventory', 'messages', 'settings',
]), 0, 80);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$this->actingAs($this->actorFor($uri), 'api');
$response = $this->withHeaders(['Accept' => 'application/json'])->json($method, $this->materializePath($uri), $this->payloadFor($method, $uri));
$this->assertControlled($response, $method, $uri);
$this->assertJsonResponseWhenSuccessful($response, "$method $uri with JSON Accept");
}
}
/** @test */
public function unsupported_accept_headers_do_not_trigger_server_errors(): void
{
foreach (array_slice($this->apiRoutes(), 0, 60) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$this->actingAs($this->actorFor($uri), 'api');
$response = $this->withHeaders(['Accept' => 'application/xml'])->json($method, $this->materializePath($uri), $this->payloadFor($method, $uri));
$this->assertStatusIn($response, [200, 201, 202, 204, 302, 304, 400, 401, 403, 404, 405, 406, 409, 415, 419, 422], "$method $uri unsupported Accept");
}
}
/** @test */
public function malformed_content_type_for_mutations_fails_cleanly(): void
{
$routes = array_slice(array_filter($this->apiRoutes(), fn ($route) => in_array($this->primaryMethod($route), ['POST', 'PUT', 'PATCH'], true)), 0, 70);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$this->actingAs($this->actorFor($uri), 'api');
$response = $this->withHeaders([
'Accept' => 'application/json',
'Content-Type' => 'text/plain',
])->call($method, $this->materializePath($uri), [], [], [], [], 'not-json=not-really');
$this->assertStatusIn($response, [200, 201, 202, 204, 400, 401, 403, 404, 405, 409, 415, 419, 422], "$method $uri malformed content type");
$this->assertLessThan(500, $response->getStatusCode(), "$method $uri should not explode on plain text bodies.");
}
}
}
@@ -0,0 +1,74 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Routing\Route as LaravelRoute;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiContractCoverageHeatmapExpansionTest extends FullSurfaceE2EContractCase
{
public function test_every_major_route_family_has_a_meaningful_contract_probe_bucket(): void
{
$families = [
'auth' => ['auth', 'login', 'logout', 'me', 'password', 'register'],
'users_roles' => ['users', 'roles', 'permissions', 'preferences', 'navigation'],
'students_families' => ['students', 'parents', 'family', 'families', 'guardian', 'authorized'],
'classes_teachers' => ['classes', 'class-sections', 'teachers', 'class-progress', 'assignments'],
'attendance_safety' => ['attendance', 'absence', 'late', 'dismissal', 'incident', 'violation'],
'academics_grades' => ['scores', 'homework', 'quiz', 'project', 'midterm', 'final', 'report-card', 'grading'],
'finance' => ['finance', 'invoice', 'payment', 'refund', 'fee', 'installment', 'charge', 'reimbursement'],
'inventory_procurement' => ['inventory', 'supplier', 'supply', 'procurement', 'purchase'],
'communications' => ['messages', 'support', 'contact', 'email', 'whatsapp', 'notification'],
'documents_print' => ['print', 'certificate', 'badge', 'sticker', 'receipt', 'export', 'download'],
'operations' => ['settings', 'configuration', 'school-years', 'calendar', 'dashboard', 'reports'],
];
$routes = $this->apiRoutes();
foreach ($families as $family => $needles) {
$matches = array_filter($routes, fn (LaravelRoute $route): bool => $this->uriContainsAny($route->uri(), $needles));
$this->assertNotEmpty($matches, $family . ' must have route-level E2E contract coverage candidates.');
}
}
public function test_mutating_route_surface_is_not_accidentally_unowned_by_contract_suites(): void
{
$ownedNeedles = [
'auth', 'users', 'roles', 'permissions', 'students', 'parents', 'family', 'guardian', 'authorized',
'classes', 'teachers', 'attendance', 'scores', 'homework', 'quiz', 'project', 'midterm', 'final',
'finance', 'invoice', 'payment', 'refund', 'fee', 'inventory', 'supplier', 'messages', 'support',
'contact', 'email', 'whatsapp', 'print', 'certificate', 'badge', 'settings', 'configuration', 'school-years',
'calendar', 'reports', 'promotion', 'withdrawal', 'enrollment', 'assignment', 'expense', 'reimbursement',
];
$unowned = [];
foreach ($this->apiRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
if (! $this->uriContainsAny($uri, $ownedNeedles)) {
$unowned[] = $method . ' ' . $uri;
}
}
$this->assertSame([], $unowned, 'Mutating API routes must be assigned to a full-surface use-case contract bucket: ' . implode(', ', $unowned));
}
/** @param list<string> $needles */
private function uriContainsAny(string $uri, array $needles): bool
{
foreach ($needles as $needle) {
if (str_contains($uri, $needle)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,58 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiContractSnapshotSeedTest extends FullSurfaceE2EContractCase
{
public function test_core_success_payloads_keep_contract_keys_when_available(): void
{
foreach ($this->contractSamples() as $label => $sample) {
$response = $this->requestAs($sample['actor'], $sample['method'], $sample['path'], $sample['payload'] ?? []);
$this->assertStatusIn($response, [200, 201, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], $label);
$this->assertNoServerError($response, $label);
if (in_array($response->getStatusCode(), [200, 201, 202], true) && $response->getContent() !== '' && $this->isJsonString($response->getContent())) {
$flat = json_encode($response->json(), JSON_THROW_ON_ERROR);
foreach ($sample['expected_any'] as $key) {
if (str_contains($flat, $key)) {
$this->assertTrue(true);
continue 2;
}
}
$this->fail($label . ' did not expose any expected contract key: ' . implode(', ', $sample['expected_any']));
}
}
}
public function test_auth_identity_payload_never_drops_user_identity_when_successful(): void
{
$response = $this->requestAs($this->admin, 'GET', 'api/v1/auth/me');
$this->assertStatusIn($response, [200, 204, 302, 401, 403, 404, 405, 419, 422], 'GET api/v1/auth/me');
$this->assertNoServerError($response, 'GET api/v1/auth/me');
if ($response->getStatusCode() === 200 && $this->isJsonString($response->getContent())) {
$flat = json_encode($response->json(), JSON_THROW_ON_ERROR);
$this->assertStringContainsString('email', $flat, 'auth/me successful payload should expose identity email.');
$this->assertStringContainsString('id', $flat, 'auth/me successful payload should expose identity id.');
}
}
/** @return array<string,array{actor:mixed,method:string,path:string,payload?:array<string,mixed>,expected_any:list<string>}> */
private function contractSamples(): array
{
return [
'users index contract' => ['actor' => $this->admin, 'method' => 'GET', 'path' => 'api/v1/users', 'expected_any' => ['data', 'users', 'id', 'email']],
'students index contract' => ['actor' => $this->admin, 'method' => 'GET', 'path' => 'api/v1/students', 'expected_any' => ['data', 'students', 'student_id', 'firstname']],
'parent profile contract' => ['actor' => $this->parent, 'method' => 'GET', 'path' => 'api/v1/parents/profile', 'expected_any' => ['user', 'parent', 'email', 'firstname']],
'teacher classes contract' => ['actor' => $this->teacher, 'method' => 'GET', 'path' => 'api/v1/teacher/classes', 'expected_any' => ['classes', 'class_section_id', 'data']],
'finance invoices contract' => ['actor' => $this->admin, 'method' => 'GET', 'path' => 'api/v1/finance/invoices', 'expected_any' => ['invoice', 'amount', 'data', 'parent_id']],
'inventory items contract' => ['actor' => $this->admin, 'method' => 'GET', 'path' => 'api/v1/inventory/items', 'expected_any' => ['item', 'quantity', 'data', 'name']],
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiCrossModuleReferentialWorkflowExpansionTest extends FullSurfaceE2EContractCase
{
/** @test */
public function wrong_but_existing_ids_across_modules_do_not_silently_cross_link_records(): void
{
$routes = array_slice(array_filter($this->apiRoutes(), fn ($route) => in_array($this->primaryMethod($route), ['POST', 'PUT', 'PATCH'], true)), 0, 120);
$crossLinked = [
'student_id' => $this->ids['invoiceId'],
'invoice_id' => $this->ids['studentId'],
'parent_id' => $this->teacher->id,
'teacher_id' => $this->parent->id,
'class_section_id' => $this->ids['parentId'],
'user_id' => $this->ids['studentId'],
];
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$response = $this->requestAs($this->actorFor($uri), $method, $uri, $this->payloadFor($method, $uri) + $crossLinked);
$this->assertControlled($response, $method, $uri);
$this->assertLessThan(500, $response->getStatusCode(), "$method $uri should not explode on cross-module ID misuse.");
}
}
}
@@ -0,0 +1,46 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiCrossVersionRouteCompatibilityMatrixTest extends FullSurfaceE2EContractCase
{
public function test_v1_and_unversioned_auth_routes_keep_failure_contract_compatible(): void
{
$pairs = [
['/api/v1/auth/login', '/api/login'],
['/api/v1/auth/logout', '/api/logout'],
['/api/v1/auth/me', '/api/user'],
];
foreach ($pairs as [$versioned, $legacy]) {
$versionedResponse = str_contains($versioned, 'login')
? $this->postJson($versioned, ['email' => 'bad@example.test', 'password' => 'wrong'])
: $this->actingAs($this->admin, 'api')->getJson($versioned);
$legacyResponse = str_contains($legacy, 'login')
? $this->postJson($legacy, ['email' => 'bad@example.test', 'password' => 'wrong'])
: $this->actingAs($this->admin, 'api')->getJson($legacy);
$this->assertNoServerError($versionedResponse, 'versioned compatibility ' . $versioned);
$this->assertNoServerError($legacyResponse, 'legacy compatibility ' . $legacy);
$this->assertLessThan(500, $versionedResponse->getStatusCode());
$this->assertLessThan(500, $legacyResponse->getStatusCode());
}
}
public function test_legacy_routes_do_not_expand_beyond_documented_aliases_silently(): void
{
$legacyLike = [];
foreach ($this->apiRoutes() as $route) {
$uri = $route->uri();
if (! str_starts_with($uri, 'api/v1/') && ! str_contains($uri, 'docs') && ! str_contains($uri, 'health')) {
$legacyLike[] = $this->primaryMethod($route) . ' ' . $uri;
}
}
$this->assertLessThan(80, count($legacyLike), 'Unversioned API compatibility routes should remain bounded, not become a second API by accident: ' . implode(', ', $legacyLike));
}
}
@@ -0,0 +1,35 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiCsvExcelFormulaInjectionContractTest extends FullSurfaceE2EContractCase
{
public function test_exportable_and_importable_text_fields_handle_excel_formula_payloads(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['export', 'import', 'students', 'parents', 'finance', 'inventory', 'reports', 'messages']), 0, 140);
$formulas = ['=HYPERLINK("https://evil.example","click")', '+cmd|\'/C calc\'!A0', '-10+20', '@SUM(1+1)'];
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
foreach ($formulas as $formula) {
$payload = $this->payloadFor($method, $uri) + [
'name' => $formula,
'firstname' => $formula,
'lastname' => $formula,
'notes' => $formula,
'description' => $formula,
'csv_value' => $formula,
];
$response = $this->requestAs($this->actorFor($uri), $method, $uri, $payload);
$this->assertControlled($response, $method, $uri);
$this->assertNoServerError($response, 'CSV formula payload at ' . $method . ' ' . $uri);
}
}
}
}
@@ -0,0 +1,61 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDataExportLeastPrivilegeContractTest extends FullSurfaceE2EContractCase
{
public function test_export_and_download_routes_do_not_grant_low_privilege_bulk_access(): void
{
$routes = $this->apiRoutesContainingAny(['export', 'download', 'pdf', 'csv', 'excel', 'print', 'receipt', 'certificate']);
$actors = [
'parent' => $this->parent,
'teacher' => $this->teacher,
'student' => $this->studentUser,
];
foreach ($actors as $label => $actor) {
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if ($method !== 'GET') {
continue;
}
$uri = $route->uri();
$response = $this->requestAs($actor, $method, $uri . '?all=true&include_sensitive=true&format=csv');
$this->assertNoServerError($response, "$label GET $uri export");
$this->assertStatusIn($response, [200, 202, 204, 302, 400, 401, 403, 404, 409, 419, 422], "$label GET $uri export");
if ($label !== 'admin' && $response->getStatusCode() === 200) {
$body = $response->getContent();
$this->assertStringNotContainsStringIgnoringCase('password', $body, "$uri export must not leak passwords.");
$this->assertStringNotContainsStringIgnoringCase('remember_token', $body, "$uri export must not leak remember tokens.");
$this->assertStringNotContainsStringIgnoringCase('all parents', $body, "$uri export must not leak bulk family scope text.");
}
}
}
}
public function test_export_filters_respect_actor_scope_parameters(): void
{
$routes = $this->apiRoutesContainingAny(['report', 'export', 'download']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if ($method !== 'GET') {
continue;
}
$uri = $route->uri();
$query = '?parent_id=999999&student_id=999999&class_section_id=999999&scope=all&include=financial,medical,contacts';
foreach (['parent' => $this->parent, 'teacher' => $this->teacher] as $label => $actor) {
$response = $this->requestAs($actor, 'GET', $uri . $query);
$this->assertNoServerError($response, "$label GET $uri scoped export");
$this->assertStatusIn($response, [200, 202, 204, 302, 400, 401, 403, 404, 409, 419, 422], "$label GET $uri scoped export");
}
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDataMinimizationByRoleContractTest extends FullSurfaceE2EContractCase
{
public function test_parent_and_student_responses_do_not_expose_back_office_finance_or_admin_fields(): void
{
$routes = array_slice($this->apiRoutesContainingAny(['parent', 'parents', 'student', 'students', 'dashboard', 'profile', 'invoices']), 0, 120);
$forbidden = ['cost_basis', 'internal_notes', 'admin_notes', 'deleted_at', 'password', 'remember_token', 'permissions', 'roles_raw', 'salary', 'ssn'];
foreach ($routes as $route) {
if ($this->primaryMethod($route) !== 'GET') {
continue;
}
$uri = $route->uri();
foreach ([$this->parent, $this->studentUser] as $actor) {
$response = $this->requestAs($actor, 'GET', $uri);
$this->assertControlled($response, 'GET', $uri);
$this->assertNoServerError($response, 'data minimization at GET ' . $uri);
if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300) {
foreach ($forbidden as $field) {
$this->assertStringNotContainsString('"' . $field . '"', $response->getContent(), 'Low-privilege response leaked ' . $field . ' at GET ' . $uri);
}
}
}
}
}
}
@@ -0,0 +1,61 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDataShapeBackwardCompatibilityContractTest extends FullSurfaceE2EContractCase
{
public function test_collection_endpoints_keep_backward_compatible_envelope_keys(): void
{
$routes = array_slice(array_filter($this->apiRoutes(), fn ($route) => $this->primaryMethod($route) === 'GET' && ! str_contains($route->uri(), '{')), 0, 120);
foreach ($routes as $route) {
$uri = $route->uri();
$response = $this->actingAs($this->actorFor($uri), 'api')->getJson($this->materializePath($uri));
$this->assertControlled($response, 'GET', $uri);
$this->assertNoServerError($response, 'collection envelope ' . $uri);
if ($response->getStatusCode() !== 200 || ! $this->isJsonString($response->getContent())) {
continue;
}
$payload = $response->json();
$this->assertTrue(
is_array($payload) && (
array_is_list($payload)
|| array_key_exists('data', $payload)
|| array_key_exists('items', $payload)
|| array_key_exists('results', $payload)
|| array_key_exists('message', $payload)
|| array_key_exists('status', $payload)
),
$uri . ' should keep a predictable collection/read envelope.'
);
}
}
public function test_successful_detail_endpoints_keep_identifier_or_data_keys(): void
{
$routes = array_slice(array_filter($this->apiRoutes(), fn ($route) => $this->primaryMethod($route) === 'GET' && str_contains($route->uri(), '{')), 0, 100);
foreach ($routes as $route) {
$uri = $route->uri();
$response = $this->actingAs($this->actorFor($uri), 'api')->getJson($this->materializePath($uri));
$this->assertControlled($response, 'GET', $uri);
$this->assertNoServerError($response, 'detail envelope ' . $uri);
if ($response->getStatusCode() !== 200 || ! $this->isJsonString($response->getContent())) {
continue;
}
$payload = $response->json();
$this->assertTrue(
data_get($payload, 'id') !== null || data_get($payload, 'data.id') !== null || data_get($payload, 'message') !== null || data_get($payload, 'status') !== null,
$uri . ' should keep an identifier, data wrapper, or explicit status/message contract.'
);
}
}
}
@@ -0,0 +1,62 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Support\Facades\DB;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDatabaseTransactionRollbackContractTest extends FullSurfaceE2EContractCase
{
public function test_multi_step_mutations_do_not_leave_obvious_partial_records_after_validation_failure(): void
{
$routes = $this->apiRoutesContainingAny(['students', 'users', 'finance', 'payments', 'inventory', 'reimbursements', 'families', 'guardians']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
$uri = $route->uri();
$beforeUsers = DB::table('users')->count();
$beforeStudents = DB::table('students')->count();
$payload = $this->payloadFor($method, $uri) + [
'email' => 'rollback.' . uniqid() . '@example.test',
'firstname' => 'Rollback',
'lastname' => 'Probe',
'parent_id' => 999999,
'student_id' => 999999,
'class_section_id' => 999999,
'force_validation_failure' => true,
];
$response = $this->requestAs($this->actorFor($uri), $method, $uri, $payload);
$this->assertNoServerError($response, "$method $uri rollback probe");
if (in_array($response->getStatusCode(), [400, 401, 403, 404, 409, 419, 422], true)) {
$this->assertLessThanOrEqual($beforeUsers + 1, DB::table('users')->count(), "$uri should not spray partial user rows on failed mutation.");
$this->assertLessThanOrEqual($beforeStudents + 1, DB::table('students')->count(), "$uri should not spray partial student rows on failed mutation.");
}
}
}
public function test_delete_failures_do_not_remove_parent_or_student_fixtures(): void
{
$routes = $this->apiRoutesContainingAny(['delete', 'remove', 'withdraw', 'archive']);
foreach ($routes as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['DELETE', 'POST', 'PATCH'], true)) {
continue;
}
$uri = $route->uri();
$response = $this->requestAs($this->actorFor($uri), $method, $uri, ['id' => 999999, 'ids' => [999999]]);
$this->assertNoServerError($response, "$method $uri destructive rollback");
$this->assertNotNull(DB::table('users')->where('id', $this->parent->id)->first(), "$uri must not delete seeded parent fixture during failed destructive action.");
$this->assertNotNull(DB::table('students')->where('id', $this->ids['studentId'])->first(), "$uri must not delete seeded student fixture during failed destructive action.");
}
}
}
@@ -0,0 +1,68 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDateMoneyAndTimezoneBoundaryContractTest extends FullSurfaceE2EContractCase
{
public function test_date_accepting_endpoints_reject_impossible_or_ambiguous_dates_cleanly(): void
{
$badDates = [
'2025-02-30',
'2025-13-01',
'not-a-date',
'01/02/03',
'9999-99-99',
];
foreach ($this->apiRoutesContainingAny(['attendance', 'finance', 'reports', 'school-years', 'calendar', 'payments', 'invoices']) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['GET', 'POST', 'PUT', 'PATCH'], true)) {
continue;
}
foreach ($badDates as $badDate) {
$payload = $this->payloadFor($method, $route->uri()) + [
'date' => $badDate,
'from' => $badDate,
'to' => $badDate,
'start_date' => $badDate,
'end_date' => $badDate,
];
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $payload);
$this->assertStatusIn($response, [200, 204, 400, 403, 404, 409, 422], "{$method} {$route->uri()} bad date {$badDate}");
$this->assertStringNotContainsString('SQLSTATE', $response->getContent());
}
}
}
public function test_money_accepting_endpoints_reject_negative_nan_and_overflow_amounts_cleanly(): void
{
$badAmounts = [-1, -0.01, 0, 'NaN', 'Infinity', '999999999999999999999999', '1e309'];
foreach ($this->apiRoutesContainingAny(['finance', 'payments', 'refunds', 'invoices', 'fees', 'reimbursements', 'expenses']) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
foreach ($badAmounts as $amount) {
$payload = $this->payloadFor($method, $route->uri()) + [
'amount' => $amount,
'paid_amount' => $amount,
'total' => $amount,
'unit_cost' => $amount,
'fee' => $amount,
];
$response = $this->requestAs($this->actorFor($route->uri()), $method, $route->uri(), $payload);
$this->assertStatusIn($response, [400, 403, 404, 409, 422], "{$method} {$route->uri()} should reject bad amount.");
$this->assertStringNotContainsString('SQLSTATE', $response->getContent());
}
}
}
}
@@ -0,0 +1,70 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use App\Models\User;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDeepAuthorizationIsolationMatrixTest extends FullSurfaceE2EContractCase
{
public function test_role_isolation_matrix_blocks_cross_portal_access_for_sensitive_surfaces(): void
{
$failures = [];
foreach ($this->isolationMatrix() as $label => [$actor, $method, $uri]) {
$response = $this->requestAs($actor, $method, $uri, $this->payloadFor($method, ltrim($uri, '/')));
$status = $response->getStatusCode();
if (! in_array($status, [401, 403, 404, 405, 419, 422], true)) {
$failures[] = "$label allowed cross-portal access with $status: " . $response->getContent();
}
}
$this->assertSame([], $failures, "Cross-role authorization isolation failed:\n" . implode("\n", $failures));
}
public function test_owner_scoped_parent_routes_do_not_expose_another_family_student(): void
{
$otherWorld = $this->seedTeacherClassWithStudent(9702);
$foreignStudentId = (int) $otherWorld['student_id'];
$checks = [
['GET', "/api/v1/parents/students/$foreignStudentId"],
['PATCH', "/api/v1/parents/students/$foreignStudentId"],
['GET', "/api/v1/parents/students/$foreignStudentId/attendance"],
['GET', "/api/v1/parents/students/$foreignStudentId/invoices"],
['POST', "/api/v1/parents/students/$foreignStudentId/emergency-contacts"],
];
$failures = [];
foreach ($checks as [$method, $uri]) {
$response = $this->requestAs($this->parent, $method, $uri, $this->payloadFor($method, ltrim($uri, '/')));
$status = $response->getStatusCode();
if (! in_array($status, [401, 403, 404, 405, 419, 422], true)) {
$failures[] = "$method $uri leaked cross-family access with $status: " . $response->getContent();
}
}
$this->assertSame([], $failures, "Parent owner-scope boundaries failed:\n" . implode("\n", $failures));
}
/** @return array<string, array{0: User, 1: string, 2: string}> */
private function isolationMatrix(): array
{
return [
'parent cannot see admin metrics' => [$this->parent, 'GET', '/api/v1/administrator/dashboard/metrics'],
'parent cannot manage school years' => [$this->parent, 'POST', '/api/v1/school-years'],
'parent cannot close school year' => [$this->parent, 'POST', '/api/v1/school-years/{schoolYear}/close'],
'parent cannot view finance report' => [$this->parent, 'GET', '/api/v1/finance/financial-report'],
'parent cannot manage inventory' => [$this->parent, 'POST', '/api/v1/inventory/items'],
'teacher cannot manage users' => [$this->teacher, 'POST', '/api/v1/users'],
'teacher cannot manage role permissions' => [$this->teacher, 'POST', '/api/v1/roles/{roleId}/permissions'],
'teacher cannot close school year' => [$this->teacher, 'POST', '/api/v1/school-years/{schoolYear}/close'],
'student cannot access teacher classes' => [$this->studentUser, 'GET', '/api/v1/teachers/classes'],
'student cannot access parent profile' => [$this->studentUser, 'GET', '/api/v1/parents/profile'],
'student cannot access admin dashboard' => [$this->studentUser, 'GET', '/api/v1/administrator/dashboard/metrics'],
'admin cannot mutate parent-scoped child profile as portal parent' => [$this->admin, 'PATCH', '/api/v1/parents/students/{studentId}'],
];
}
}
@@ -0,0 +1,76 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Routing\Route as LaravelRoute;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDeepDocumentationAndRouteParityContractTest extends FullSurfaceE2EContractCase
{
public function test_every_named_api_route_has_a_stable_route_name_or_explicit_public_exception(): void
{
$unnamed = [];
foreach ($this->apiRoutes() as $route) {
$uri = $route->uri();
if ($route->getName() !== null) {
continue;
}
if ($this->isAllowedUnnamedRoute($uri)) {
continue;
}
$unnamed[] = $this->primaryMethod($route) . ' ' . $uri;
}
$this->assertSame([], $unnamed, "API routes should be nameable for docs, client generation, and redirects. Unnamed routes:\n" . implode("\n", $unnamed));
}
public function test_openapi_or_docs_catalog_endpoint_is_reachable_and_route_table_remains_documentable(): void
{
$docsChecks = [
['GET', '/api/documentation'],
['GET', '/api/documentation/swagger'],
['GET', '/api/documentation/swagger.json'],
['GET', '/api/docs/public'],
];
$failures = [];
foreach ($docsChecks as [$method, $uri]) {
$response = $this->requestAs($this->admin, $method, $uri);
if ($response->getStatusCode() >= 500) {
$failures[] = "$method $uri docs endpoint crashed: " . $response->getContent();
}
}
$this->assertSame([], $failures, "Documentation endpoints must not be decorative rubble:\n" . implode("\n", $failures));
}
public function test_route_use_case_catalog_covers_new_deep_contract_domains(): void
{
$coverageNeedles = [
'import' => ['upload', 'import', 'attachment', 'document'],
'export' => ['download', 'export', 'pdf', 'print'],
'pagination' => ['students', 'users', 'invoices', 'inventory', 'messages'],
'state_transition' => ['close', 'reopen', 'approve', 'archive', 'resolve', 'lock', 'unlock'],
'identity' => ['auth', 'login', 'logout', 'me', 'roles'],
];
foreach ($coverageNeedles as $label => $needles) {
$this->assertNotSame([], $this->apiRoutesContainingAny($needles), "The $label contract bucket should map to at least one route.");
}
}
private function isAllowedUnnamedRoute(string $uri): bool
{
return str_contains($uri, 'api/v1')
|| str_contains($uri, 'login')
|| str_contains($uri, 'register')
|| str_contains($uri, 'proofread')
|| str_contains($uri, 'emails')
|| str_contains($uri, 'compare');
}
}
@@ -0,0 +1,82 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route as LaravelRoute;
use Illuminate\Support\Facades\Storage;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDeepFileImportExportAndPrintableContractTest extends FullSurfaceE2EContractCase
{
public function test_export_download_and_printable_routes_are_controlled_for_authenticated_users(): void
{
$failures = [];
foreach ($this->downloadLikeRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$response = $this->probeRoute($route);
$status = $response->getStatusCode();
if ($status >= 500) {
$failures[] = "$method $uri crashed when generating downloadable/printable output: " . $response->getContent();
continue;
}
if (! in_array($status, [200, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], true)) {
$failures[] = "$method $uri returned unexpected $status for downloadable/printable output: " . $response->getContent();
}
}
$this->assertSame([], $failures, "Download/export/print routes must fail cleanly when fixtures are thin:\n" . implode("\n", $failures));
}
public function test_import_upload_routes_accept_test_files_or_return_validation_without_server_errors(): void
{
Storage::fake('local');
$failures = [];
foreach ($this->uploadLikeRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'file' => UploadedFile::fake()->create('e2e-import.csv', 2, 'text/csv'),
'document' => UploadedFile::fake()->create('e2e-document.pdf', 2, 'application/pdf'),
];
$response = $this->requestAs($this->actorFor($uri), $method, $uri, $payload);
$status = $response->getStatusCode();
if ($status >= 500) {
$failures[] = "$method $uri crashed on upload/import probe: " . $response->getContent();
}
}
$this->assertSame([], $failures, "Upload/import routes must validate file input, not panic:\n" . implode("\n", $failures));
}
/** @return list<LaravelRoute> */
private function downloadLikeRoutes(): array
{
return $this->apiRoutesContainingAny(['download', 'export', 'pdf', 'print', 'certificate', 'badge', 'receipt']);
}
/** @return list<LaravelRoute> */
private function uploadLikeRoutes(): array
{
return array_values(array_filter($this->apiRoutes(), function (LaravelRoute $route): bool {
if (! in_array($this->primaryMethod($route), ['POST', 'PUT', 'PATCH'], true)) {
return false;
}
$uri = $route->uri();
return str_contains($uri, 'upload')
|| str_contains($uri, 'import')
|| str_contains($uri, 'scan')
|| str_contains($uri, 'document')
|| str_contains($uri, 'attachment');
}));
}
}
@@ -0,0 +1,95 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Routing\Route as LaravelRoute;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDeepIdempotencyAndDuplicateSubmissionContractTest extends FullSurfaceE2EContractCase
{
public function test_duplicate_sensitive_mutations_do_not_create_uncontrolled_server_errors(): void
{
$failures = [];
foreach ($this->duplicateSensitiveRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$payload = $this->payloadFor($method, $uri) + [
'external_reference' => 'E2E-DUP-' . md5($uri),
'idempotency_key' => 'E2E-DUP-' . md5($uri),
];
$first = $this->requestAs($this->actorFor($uri), $method, $uri, $payload);
$second = $this->requestAs($this->actorFor($uri), $method, $uri, $payload);
foreach (['first' => $first, 'second' => $second] as $label => $response) {
$status = $response->getStatusCode();
if ($status >= 500) {
$failures[] = "$method $uri $label duplicate request crashed with $status: " . $response->getContent();
}
if (! in_array($status, [200, 201, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], true)) {
$failures[] = "$method $uri $label duplicate request returned unexpected $status: " . $response->getContent();
}
}
}
$this->assertSame([], $failures, "Duplicate submissions need controlled behavior, because users double-click like it's a sport:\n" . implode("\n", $failures));
}
public function test_repeat_delete_or_archive_requests_are_safe_to_retry_or_fail_cleanly(): void
{
$failures = [];
foreach ($this->retryableDestructiveRoutes() as $route) {
$uri = $route->uri();
$first = $this->requestAs($this->actorFor($uri), 'DELETE', $uri);
$second = $this->requestAs($this->actorFor($uri), 'DELETE', $uri);
foreach (['first' => $first, 'second' => $second] as $label => $response) {
if ($response->getStatusCode() >= 500) {
$failures[] = "DELETE $uri $label retry crashed: " . $response->getContent();
}
}
}
$this->assertSame([], $failures, "Retrying destructive operations must not crash:\n" . implode("\n", $failures));
}
/** @return list<LaravelRoute> */
private function duplicateSensitiveRoutes(): array
{
return array_slice(array_values(array_filter($this->apiRoutes(), function (LaravelRoute $route): bool {
if ($this->primaryMethod($route) !== 'POST') {
return false;
}
$uri = $route->uri();
return str_contains($uri, 'payments')
|| str_contains($uri, 'refund')
|| str_contains($uri, 'invoices')
|| str_contains($uri, 'attendance')
|| str_contains($uri, 'messages')
|| str_contains($uri, 'support')
|| str_contains($uri, 'students')
|| str_contains($uri, 'users')
|| str_contains($uri, 'inventory')
|| str_contains($uri, 'reimbursements');
})), 0, 80);
}
/** @return list<LaravelRoute> */
private function retryableDestructiveRoutes(): array
{
return array_slice(array_values(array_filter($this->apiRoutes(), function (LaravelRoute $route): bool {
$uri = $route->uri();
return $this->primaryMethod($route) === 'DELETE'
|| str_contains($uri, 'archive')
|| str_contains($uri, 'remove')
|| str_contains($uri, 'delete');
})), 0, 50);
}
}
@@ -0,0 +1,98 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Routing\Route as LaravelRoute;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDeepPaginationSearchAndFilterContractTest extends FullSurfaceE2EContractCase
{
public function test_list_endpoints_tolerate_pagination_search_sort_and_filter_parameters(): void
{
$failures = [];
foreach ($this->listRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$path = $this->materializePath($uri) . '?' . http_build_query([
'page' => 1,
'per_page' => 10,
'search' => 'e2e',
'sort' => 'created_at',
'direction' => 'desc',
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'status' => 'active',
]);
$this->actingAs($this->actorFor($uri), 'api');
$response = $this->json($method, $path);
$status = $response->getStatusCode();
if ($status >= 500) {
$failures[] = "$method $uri crashed with list filters: " . $response->getContent();
continue;
}
if (! in_array($status, [200, 204, 302, 400, 401, 403, 404, 405, 409, 422], true)) {
$failures[] = "$method $uri returned unexpected $status with list filters: " . $response->getContent();
}
}
$this->assertSame([], $failures, "List endpoints must not crash when clients send boring grid params:\n" . implode("\n", $failures));
}
public function test_date_range_filters_fail_cleanly_when_dates_are_invalid(): void
{
$failures = [];
foreach ($this->dateFilterRoutes() as $route) {
$uri = $route->uri();
$path = $this->materializePath($uri) . '?' . http_build_query([
'from' => 'not-a-date',
'to' => 'still-not-a-date',
'start_date' => '2025-99-99',
'end_date' => 'yesterday-ish',
]);
$this->actingAs($this->actorFor($uri), 'api');
$response = $this->json('GET', $path);
if ($response->getStatusCode() >= 500) {
$failures[] = "GET $uri crashed on invalid date filters: " . $response->getContent();
}
}
$this->assertSame([], $failures, "Invalid date filters should be validation, not detonation:\n" . implode("\n", $failures));
}
/** @return list<LaravelRoute> */
private function listRoutes(): array
{
return array_values(array_filter($this->apiRoutes(), function (LaravelRoute $route): bool {
$uri = $route->uri();
return $this->primaryMethod($route) === 'GET'
&& ! preg_match('#/\{[^}]+\}$#', $uri)
&& ! str_contains($uri, 'download')
&& ! str_contains($uri, 'export')
&& ! str_contains($uri, 'pdf')
&& ! str_contains($uri, 'swagger');
}));
}
/** @return list<LaravelRoute> */
private function dateFilterRoutes(): array
{
return array_values(array_filter($this->listRoutes(), function (LaravelRoute $route): bool {
$uri = $route->uri();
return str_contains($uri, 'attendance')
|| str_contains($uri, 'finance')
|| str_contains($uri, 'reports')
|| str_contains($uri, 'payments')
|| str_contains($uri, 'invoices')
|| str_contains($uri, 'school-years');
}));
}
}
@@ -0,0 +1,99 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Routing\Route as LaravelRoute;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDeepResponseShapeAndEnvelopeContractTest extends FullSurfaceE2EContractCase
{
public function test_successful_json_api_responses_use_parseable_json_envelopes_across_major_domains(): void
{
$failures = [];
foreach ($this->representativeDomainRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$response = $this->probeRoute($route);
$status = $response->getStatusCode();
if ($status >= 500) {
$failures[] = "$method $uri crashed with $status: " . $response->getContent();
continue;
}
if ($status >= 200 && $status < 300 && $response->getContent() !== '' && ! $this->isJsonString($response->getContent())) {
$failures[] = "$method $uri returned non-json successful API response: " . $response->getContent();
}
}
$this->assertSame([], $failures, "Successful API responses should be machine-readable JSON:\n" . implode("\n", $failures));
}
public function test_collection_endpoints_expose_list_like_payloads_or_explicit_empty_state(): void
{
$failures = [];
foreach ($this->collectionLikeRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
if ($method !== 'GET') {
continue;
}
$response = $this->probeRoute($route);
$status = $response->getStatusCode();
if (! in_array($status, [200, 204, 302, 401, 403, 404, 422], true)) {
$failures[] = "$method $uri returned unexpected $status for collection contract: " . $response->getContent();
continue;
}
if ($status === 200 && $this->isJsonString($response->getContent())) {
$payload = $response->json();
$hasCollectionShape = is_array($payload)
&& (array_is_list($payload)
|| array_key_exists('data', $payload)
|| array_key_exists('items', $payload)
|| array_key_exists('results', $payload)
|| array_key_exists('rows', $payload)
|| array_key_exists('ok', $payload)
|| array_key_exists('status', $payload));
if (! $hasCollectionShape) {
$failures[] = "$method $uri returned JSON but no recognizable collection/envelope keys: " . $response->getContent();
}
}
}
$this->assertSame([], $failures, "Collection routes need a stable list/envelope shape:\n" . implode("\n", $failures));
}
/** @return list<LaravelRoute> */
private function representativeDomainRoutes(): array
{
return array_slice($this->apiRoutesContainingAny([
'auth', 'administrator', 'teachers', 'parents', 'students', 'attendance', 'scores',
'finance', 'inventory', 'messages', 'support', 'settings', 'school-years', 'families',
]), 0, 120);
}
/** @return list<LaravelRoute> */
private function collectionLikeRoutes(): array
{
return array_values(array_filter($this->apiRoutes(), function (LaravelRoute $route): bool {
if ($this->primaryMethod($route) !== 'GET') {
return false;
}
$uri = $route->uri();
return ! preg_match('#/\{[^}]+\}$#', $uri)
&& ! str_contains($uri, 'download')
&& ! str_contains($uri, 'export')
&& ! str_contains($uri, 'print')
&& ! str_contains($uri, 'pdf');
}));
}
}
@@ -0,0 +1,77 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Support\Facades\DB;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDeepStateTransitionInvariantContractTest extends FullSurfaceE2EContractCase
{
public function test_school_year_close_reopen_and_promotion_transition_routes_preserve_controlled_state(): void
{
$schoolYearId = (int) $this->ids['schoolYear'];
$checks = [
['POST', "/api/v1/school-years/$schoolYearId/validate-close", []],
['POST', "/api/v1/school-years/$schoolYearId/preview-close", []],
['POST', "/api/v1/school-years/$schoolYearId/close", ['confirm' => true]],
['POST', "/api/v1/school-years/$schoolYearId/reopen", ['reason' => 'E2E invariant probe']],
['GET', '/api/v1/administrator/promotions'],
['POST', '/api/v1/administrator/promotions/preview', ['school_year' => self::E2E_SCHOOL_YEAR]],
];
$this->assertTransitionChecksAreControlled($checks, 'school year and promotion');
}
public function test_finance_transition_routes_do_not_allow_negative_balance_crashes_or_untracked_state(): void
{
$invoiceId = (int) $this->ids['invoiceId'];
$checks = [
['POST', '/api/v1/finance/payments', ['invoice_id' => $invoiceId, 'amount' => -100, 'payment_method' => 'cash']],
['POST', '/api/v1/finance/payments', ['invoice_id' => $invoiceId, 'amount' => 25, 'payment_method' => 'cash']],
['POST', '/api/v1/finance/refunds', ['invoice_id' => $invoiceId, 'amount' => 999999, 'reason' => 'Over-refund probe']],
['POST', '/api/v1/finance/installments', ['invoice_id' => $invoiceId, 'amount' => 25, 'due_date' => '2025-11-01']],
['GET', '/api/v1/finance/payment-followups'],
];
$this->assertTransitionChecksAreControlled($checks, 'finance');
$invoice = DB::table('invoices')->where('id', $invoiceId)->first();
$this->assertNotNull($invoice, 'Finance probes must not delete the invoice fixture just because math got spicy.');
}
public function test_attendance_transition_routes_keep_single_day_student_context_controlled(): void
{
$studentId = (int) $this->ids['studentId'];
$classSectionId = (int) $this->ids['classSectionId'];
$checks = [
['POST', '/api/v1/teacher/attendance', ['student_id' => $studentId, 'class_section_id' => $classSectionId, 'date' => '2025-10-05', 'status' => 'present']],
['POST', '/api/v1/teacher/attendance', ['student_id' => $studentId, 'class_section_id' => $classSectionId, 'date' => '2025-10-05', 'status' => 'absent']],
['POST', '/api/v1/attendance/late-slips', ['student_id' => $studentId, 'class_section_id' => $classSectionId, 'date' => '2025-10-05', 'arrival_time' => '10:45']],
['POST', '/api/v1/attendance/early-dismissals', ['student_id' => $studentId, 'class_section_id' => $classSectionId, 'date' => '2025-10-05', 'dismissal_time' => '12:30']],
];
$this->assertTransitionChecksAreControlled($checks, 'attendance');
}
/** @param list<array{0: string, 1: string, 2?: array<string, mixed>}> $checks */
private function assertTransitionChecksAreControlled(array $checks, string $domain): void
{
$failures = [];
foreach ($checks as [$method, $uri, $payload]) {
$payload ??= $this->payloadFor($method, ltrim($uri, '/'));
$response = $this->requestAs($this->actorFor($uri), $method, $uri, $payload);
if ($response->getStatusCode() >= 500) {
$failures[] = "$method $uri crashed during $domain transition probe: " . $response->getContent();
continue;
}
if (! in_array($response->getStatusCode(), $this->controlledStatuses($method), true)) {
$failures[] = "$method $uri returned unexpected {$response->getStatusCode()} during $domain transition probe: " . $response->getContent();
}
}
$this->assertSame([], $failures, ucfirst($domain) . " state transitions must be controlled:\n" . implode("\n", $failures));
}
}
@@ -0,0 +1,98 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Routing\Route as LaravelRoute;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDeepValidationFailureContractTest extends FullSurfaceE2EContractCase
{
public function test_mutating_routes_return_controlled_validation_or_authorization_for_hostile_payloads(): void
{
$failures = [];
foreach ($this->mutatingRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
foreach ($this->hostilePayloads() as $label => $payload) {
$response = $this->requestAs($this->actorFor($uri), $method, $uri, $payload);
$status = $response->getStatusCode();
if ($status >= 500) {
$failures[] = "$method $uri crashed on $label hostile payload with $status: " . $response->getContent();
continue;
}
if (! in_array($status, [200, 201, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], true)) {
$failures[] = "$method $uri returned unexpected $status on $label hostile payload: " . $response->getContent();
}
}
}
$this->assertSame([], $failures, "Hostile payloads must fail cleanly, not leak framework explosions:\n" . implode("\n", $failures));
}
public function test_required_field_validation_uses_machine_readable_error_shape_when_validation_fails(): void
{
$failures = [];
foreach ($this->validationSensitiveRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$response = $this->requestAs($this->actorFor($uri), $method, $uri, []);
if ($response->getStatusCode() !== 422) {
continue;
}
if (! $this->isJsonString($response->getContent())) {
$failures[] = "$method $uri returned 422 but not JSON: " . $response->getContent();
continue;
}
$json = $response->json();
if (! is_array($json) || (! array_key_exists('errors', $json) && ! array_key_exists('message', $json))) {
$failures[] = "$method $uri returned 422 without message/errors: " . $response->getContent();
}
}
$this->assertSame([], $failures, "Validation errors must be usable by clients, not poetic prose:\n" . implode("\n", $failures));
}
/** @return list<LaravelRoute> */
private function mutatingRoutes(): array
{
return array_slice(array_values(array_filter($this->apiRoutes(), function (LaravelRoute $route): bool {
return in_array($this->primaryMethod($route), ['POST', 'PUT', 'PATCH', 'DELETE'], true)
&& ! str_contains($route->uri(), 'auth/logout');
})), 0, 150);
}
/** @return list<LaravelRoute> */
private function validationSensitiveRoutes(): array
{
return array_values(array_filter($this->mutatingRoutes(), function (LaravelRoute $route): bool {
$uri = $route->uri();
return str_contains($uri, 'students')
|| str_contains($uri, 'users')
|| str_contains($uri, 'attendance')
|| str_contains($uri, 'finance')
|| str_contains($uri, 'scores')
|| str_contains($uri, 'support')
|| str_contains($uri, 'inventory');
}));
}
/** @return array<string, array<string, mixed>> */
private function hostilePayloads(): array
{
return [
'empty' => [],
'wrong_scalar_types' => ['id' => 'not-an-int', 'amount' => 'NaN', 'date' => 'not-a-date', 'email' => 'not-email'],
'unexpected_nested_arrays' => ['name' => ['nested' => ['too' => ['deep' => true]]], 'student_id' => ['x']],
'oversized_strings' => ['name' => str_repeat('x', 1024), 'description' => str_repeat('y', 2048)],
];
}
}
@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Routing\Route as LaravelRoute;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiDestructiveActionSafeguardContractTest extends FullSurfaceE2EContractCase
{
public function test_delete_routes_handle_missing_and_foreign_ids_without_crashing(): void
{
foreach ($this->deleteRoutes() as $route) {
$uri = $this->replaceAllPlaceholdersWithMissingId($route->uri());
$response = $this->requestAs($this->actorFor($route->uri()), 'DELETE', $uri);
$this->assertStatusIn($response, [200, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], "DELETE missing $uri");
$this->assertNoServerError($response, "DELETE missing $uri");
}
}
public function test_bulk_delete_style_routes_require_explicit_targets(): void
{
foreach ($this->bulkDestructiveRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$response = $this->requestAs($this->actorFor($uri), $method, $uri, [
'ids' => [],
'confirm' => false,
]);
$this->assertStatusIn($response, [200, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], "$method $uri empty bulk destructive payload");
$this->assertNoServerError($response, "$method $uri empty bulk destructive payload");
if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300) {
$this->assertTrue(
in_array($response->getStatusCode(), [200, 202, 204], true),
"$method $uri may succeed only as an explicit no-op, never by deleting an unspecified set."
);
}
}
}
public function test_archive_remove_cancel_and_void_routes_are_idempotent_or_conflict_safe(): void
{
foreach ($this->softDestructiveRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$first = $this->requestAs($this->actorFor($uri), $method, $uri, $this->payloadFor($method, $uri));
$second = $this->requestAs($this->actorFor($uri), $method, $uri, $this->payloadFor($method, $uri));
$this->assertControlled($first, $method, $uri);
$this->assertControlled($second, $method, $uri);
$this->assertNoServerError($first, "$method $uri first soft destructive attempt");
$this->assertNoServerError($second, "$method $uri second soft destructive attempt");
}
}
/** @return list<LaravelRoute> */
private function deleteRoutes(): array
{
return array_slice(array_filter($this->apiRoutes(), fn (LaravelRoute $route): bool => $this->primaryMethod($route) === 'DELETE'), 0, 80);
}
/** @return list<LaravelRoute> */
private function bulkDestructiveRoutes(): array
{
return array_slice(array_filter($this->apiRoutes(), function (LaravelRoute $route): bool {
$uri = $route->uri();
return in_array($this->primaryMethod($route), ['POST', 'DELETE'], true)
&& preg_match('/bulk|batch|mass|selected|purge|delete/i', $uri) === 1;
}), 0, 60);
}
/** @return list<LaravelRoute> */
private function softDestructiveRoutes(): array
{
return array_slice(array_filter($this->apiRoutes(), function (LaravelRoute $route): bool {
return preg_match('/archive|remove|cancel|void|resolve|close|reopen|restore/i', $route->uri()) === 1;
}), 0, 80);
}
private function replaceAllPlaceholdersWithMissingId(string $uri): string
{
return preg_replace('/\{[^}]+\}/', '999999999', $uri) ?? $uri;
}
}
@@ -0,0 +1,92 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Illuminate\Support\Facades\DB;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiEmptyDatasetAndNullStateContractTest extends FullSurfaceE2EContractCase
{
public function test_list_endpoints_return_stable_empty_shapes_when_filters_match_nothing(): void
{
foreach ($this->listPaths() as $path) {
$response = $this->requestAs($this->actorFor($path), 'GET', $path . '?search=__no_match_contract_probe__&page=1&per_page=5');
$this->assertStatusIn($response, [200, 204, 302, 400, 401, 403, 404, 405, 419, 422], "empty list $path");
$this->assertNoServerError($response, "empty list $path");
if ($response->getStatusCode() === 200 && $this->isJsonString($response->getContent())) {
$json = $response->json();
$this->assertTrue(
is_array($json) || $json === null,
"$path empty list response should remain array/envelope/null, not scalar junk."
);
}
}
}
public function test_current_school_year_null_state_is_handled_without_crash(): void
{
DB::table('school_years')->update(['is_current' => false]);
foreach ($this->schoolContextPaths() as $path) {
$response = $this->requestAs($this->admin, 'GET', $path);
$this->assertStatusIn($response, [200, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], "null school year $path");
$this->assertNoServerError($response, "null school year $path");
}
}
public function test_empty_class_roster_and_attendance_context_are_controlled(): void
{
$emptyClassSectionId = $this->seedClassSection(9911, 'Empty Contract Class', 99);
$this->ids['classSectionId'] = $emptyClassSectionId;
$this->ids['class_section_id'] = $emptyClassSectionId;
foreach ($this->emptyClassPaths($emptyClassSectionId) as $path) {
$response = $this->requestAs($this->admin, 'GET', $path);
$this->assertStatusIn($response, [200, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], "empty class $path");
$this->assertNoServerError($response, "empty class $path");
}
}
/** @return list<string> */
private function listPaths(): array
{
return [
'api/v1/users',
'api/v1/students',
'api/v1/classes',
'api/v1/attendance',
'api/v1/finance/invoices',
'api/v1/inventory/items',
'api/v1/messages',
'api/v1/support',
];
}
/** @return list<string> */
private function schoolContextPaths(): array
{
return [
'api/v1/system/semester',
'api/v1/school-years/current',
'api/v1/classes',
'api/v1/attendance',
'api/v1/report-cards',
'api/v1/finance/reports',
];
}
/** @return list<string> */
private function emptyClassPaths(int $classSectionId): array
{
return [
"api/v1/classes/$classSectionId/students",
"api/v1/teacher/classes/$classSectionId/roster",
"api/v1/attendance/class/$classSectionId",
"api/v1/class-progress/$classSectionId",
];
}
}
@@ -0,0 +1,44 @@
<?php
namespace Tests\Feature\Api\V1\FullSurface;
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
class ApiEnumAndStateMachineContractTest extends FullSurfaceE2EContractCase
{
public function test_status_fields_reject_unknown_states_without_defaulting_to_success(): void
{
$routes = $this->apiRoutesContainingAny(['attendance', 'invoice', 'payment', 'refund', 'support', 'message', 'inventory', 'promotion', 'school-year']);
$states = ['approved_by_accident', 'null', '', 'DROP TABLE', 'deleted-but-visible', 'paid_and_refunded'];
foreach (array_slice($routes, 0, 120) as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
continue;
}
foreach ($states as $state) {
$uri = $route->uri();
$response = $this->requestAs($this->actorFor($uri), $method, $uri, $this->payloadFor($method, $uri) + ['status' => $state, 'state' => $state]);
$this->assertControlled($response, $method, $uri);
$this->assertNoServerError($response, 'unknown status ' . $state . ' at ' . $method . ' ' . $uri);
}
}
}
public function test_terminal_states_are_not_reopened_by_low_privilege_users(): void
{
$routes = $this->apiRoutesContainingAny(['reopen', 'restore', 'unarchive', 'cancel', 'void', 'resolve']);
foreach (array_slice($routes, 0, 80) as $route) {
$method = $this->primaryMethod($route);
$uri = $route->uri();
$response = $this->requestAs($this->parent, $method, $uri, $this->payloadFor($method, $uri));
$this->assertStatusIn($response, [400, 401, 403, 404, 405, 409, 419, 422], 'low privilege terminal state transition ' . $method . ' ' . $uri);
$this->assertNoServerError($response, 'low privilege terminal state transition ' . $method . ' ' . $uri);
}
}
}

Some files were not shown because too many files have changed in this diff Show More