*/ private array $ids = []; private User $admin; private User $teacher; private User $parent; private User $studentUser; protected function setUp(): void { parent::setUp(); $this->seedE2EConfiguration(); $world = $this->seedTeacherClassWithStudent(9101); $this->teacher = $world['teacher']; $this->parent = $world['parent']; $this->studentUser = $this->createApiUserWithRole('student'); $this->admin = $this->createApiUserWithRole('administrator'); $invoiceId = $this->seedInvoiceForParent((int) $world['parent_id']); $voucherId = $this->seedDiscountVoucher(); $this->ids = [ 'id' => 1, 'userId' => $this->admin->id, 'roleId' => (int) DB::table('roles')->where('name', 'parent')->value('id'), 'permissionId' => 1, 'student' => (int) $world['student_id'], 'studentId' => (int) $world['student_id'], 'parent' => (int) $world['parent_id'], 'parentId' => (int) $world['parent_id'], 'teacher' => $this->teacher->id, 'teacherId' => $this->teacher->id, 'classSection' => (int) $world['class_section_id'], 'classSectionId' => (int) $world['class_section_id'], 'class_section_id' => (int) $world['class_section_id'], 'invoice' => $invoiceId, 'invoiceId' => $invoiceId, 'payment' => 1, 'paymentId' => 1, 'refund' => 1, 'refundId' => 1, 'voucher' => $voucherId, 'schoolYear' => (int) DB::table('school_years')->where('name', self::E2E_SCHOOL_YEAR)->value('id'), 'promotionId' => 1, 'batchId' => 1, 'contactId' => 1, 'familyId' => 1, 'authorizedUserId' => 1, 'eventCharge' => 1, 'plan' => 1, 'carryforward' => 1, 'installment' => 1, 'certificateNumber' => 'CERT-E2E-001', 'token' => 'invalid-token-for-contract-probe', 'name' => 'placeholder.txt', 'mode' => 'view', ]; } public function test_every_registered_api_route_has_a_controlled_e2e_contract_response(): void { $failures = []; foreach ($this->probeableApiRoutes() as $route) { $method = $this->primaryMethod($route); $uri = '/'.$this->materializeUri($route->uri()); $actor = $this->actorFor($route->uri()); $payload = $this->payloadFor($method, $route->uri()); $this->actingAs($actor, 'api'); $response = $this->json($method, $uri, $payload); $status = $response->getStatusCode(); if ($status >= 500) { $failures[] = "$method $uri crashed with $status: ".$response->getContent(); continue; } if (! in_array($status, $this->allowedContractStatuses($method), true)) { $failures[] = "$method $uri returned unexpected $status: ".$response->getContent(); } } $this->assertSame([], $failures, "Full-surface API contract failures:\n".implode("\n", $failures)); } public function test_all_mutating_api_routes_reject_empty_or_malformed_payloads_without_server_errors(): void { $failures = []; foreach ($this->probeableApiRoutes() as $route) { $method = $this->primaryMethod($route); if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { continue; } $uri = '/'.$this->materializeUri($route->uri()); $this->actingAs($this->actorFor($route->uri()), 'api'); $response = $this->json($method, $uri, []); $status = $response->getStatusCode(); if ($status >= 500) { $failures[] = "$method $uri malformed payload crashed with $status: ".$response->getContent(); } } $this->assertSame([], $failures, "Malformed-payload probes must fail cleanly, not explode:\n".implode("\n", $failures)); } public function test_portal_routes_enforce_wrong_actor_boundaries_across_the_full_surface(): void { $failures = []; foreach ($this->portalBoundaryProbes() as [$actor, $method, $uri]) { $this->actingAs($actor, 'api'); $response = $this->json($method, $this->materializePath($uri), $this->payloadFor($method, ltrim($uri, '/'))); $status = $response->getStatusCode(); if (! in_array($status, [401, 403, 404, 405, 419, 422], true)) { $failures[] = "$method $uri allowed wrong actor with $status: ".$response->getContent(); } } $this->assertSame([], $failures, "Wrong-role portal boundary failures:\n".implode("\n", $failures)); } /** * @return list */ private function probeableApiRoutes(): array { return array_values(array_filter(Route::getRoutes()->getRoutes(), function (LaravelRoute $route): bool { $uri = $route->uri(); if (! str_starts_with($uri, 'api/')) { return false; } if (str_contains($uri, 'documentation/swagger.json')) { return false; } if (str_contains($uri, 'files/') || str_contains($uri, '/pdf') || str_contains($uri, '/csv')) { return false; } return ! in_array($this->primaryMethod($route), ['HEAD', 'OPTIONS'], true); })); } private function primaryMethod(LaravelRoute $route): string { foreach ($route->methods() as $method) { if (! in_array($method, ['HEAD', 'OPTIONS'], true)) { return $method; } } return 'GET'; } private function actorFor(string $uri): User { if (str_contains($uri, '/parents') || str_contains($uri, '/parent') || str_contains($uri, 'confirm_authorized_user')) { return $this->parent; } if (str_contains($uri, '/teacher') || str_contains($uri, '/teachers') || str_contains($uri, 'class-progress')) { return $this->teacher; } if (str_contains($uri, '/students/dashboard')) { return $this->studentUser; } return $this->admin; } /** * @return list */ private function portalBoundaryProbes(): array { return [ [$this->parent, 'GET', '/api/v1/administrator/dashboard/metrics'], [$this->parent, 'GET', '/api/v1/administrator/enrollment-withdrawal'], [$this->teacher, 'GET', '/api/v1/parents/profile'], [$this->admin, 'PATCH', '/api/v1/parents/students/{studentId}'], [$this->parent, 'GET', '/api/v1/finance/financial-report'], [$this->parent, 'GET', '/api/v1/inventory/items'], [$this->teacher, 'POST', '/api/v1/school-years/{schoolYear}/close'], [$this->studentUser, 'GET', '/api/v1/teachers/classes'], ]; } private function materializeUri(string $uri): string { return preg_replace_callback('/\{([^}:]+)(?:\?)?[^}]*\}/', function (array $matches): string { $key = $matches[1]; return (string) ($this->ids[$key] ?? $this->ids['id']); }, $uri) ?? $uri; } private function materializePath(string $path): string { return '/'.ltrim($this->materializeUri(ltrim($path, '/')), '/'); } /** * @return array */ private function payloadFor(string $method, string $uri): array { if ($method === 'GET' || $method === 'DELETE') { return []; } $base = [ 'school_year' => self::E2E_SCHOOL_YEAR, 'semester' => self::E2E_SEMESTER, 'date' => '2025-10-05', 'notes' => 'E2E full-surface contract probe', ]; if (str_contains($uri, 'auth/login') || preg_match('#api/(?:v1/)?login$#', $uri)) { return ['email' => $this->admin->email, 'password' => 'password']; } if (str_contains($uri, 'users')) { return $this->validUserPayload((int) $this->ids['roleId']); } if (str_contains($uri, 'students')) { return $base + [ 'firstname' => 'Contract', 'lastname' => 'Student', 'dob' => '2015-01-01', 'gender' => 'Male', 'parent_id' => $this->ids['parentId'], 'student_id' => $this->ids['studentId'], 'class_section_id' => [$this->ids['classSectionId']], ]; } if (str_contains($uri, 'attendance')) { return $base + [ 'student_id' => $this->ids['studentId'], 'class_section_id' => $this->ids['classSectionId'], 'status' => 'present', 'attendance' => [$this->ids['studentId'] => 'present'], 'arrival_time' => '10:05', ]; } if (str_contains($uri, 'scores') || str_contains($uri, 'grading')) { return $base + [ 'student_id' => $this->ids['studentId'], 'class_section_id' => $this->ids['classSectionId'], 'score' => 95, 'points' => 10, 'column' => 'Contract Probe', 'comment' => 'Solid work.', ]; } if (str_contains($uri, 'finance') || str_contains($uri, 'invoice') || str_contains($uri, 'payment') || str_contains($uri, 'refund')) { return $base + [ 'parent_id' => $this->ids['parentId'], 'student_id' => $this->ids['studentId'], 'invoice_id' => $this->ids['invoiceId'], 'amount' => 25.00, 'payment_method' => 'cash', 'reason' => 'E2E contract probe', ]; } if (str_contains($uri, 'inventory') || str_contains($uri, 'suppliers') || str_contains($uri, 'supply')) { return $base + [ 'name' => 'Contract Probe Item', 'sku' => 'E2E-'.uniqid(), 'quantity' => 3, 'unit_cost' => 5.25, ]; } if (str_contains($uri, 'messages') || str_contains($uri, 'support') || str_contains($uri, 'contact') || str_contains($uri, 'email')) { return $base + [ 'subject' => 'Contract Probe', 'message' => 'This is an automated E2E contract probe.', 'body' => 'This is an automated E2E contract probe.', 'email' => 'contract.probe@example.test', 'recipient_id' => $this->teacher->id, ]; } if (str_contains($uri, 'preferences') || str_contains($uri, 'settings') || str_contains($uri, 'configuration')) { return $base + [ 'key' => 'contract_probe', 'value' => 'enabled', 'config_key' => 'contract_probe', 'config_value' => 'enabled', ]; } if (str_contains($uri, 'families') || str_contains($uri, 'authorized')) { return $base + [ 'parent_id' => $this->ids['parentId'], 'student_id' => $this->ids['studentId'], 'email' => 'guardian.contract@example.test', 'firstname' => 'Guardian', 'lastname' => 'Probe', ]; } return $base + [ 'name' => 'Contract Probe', 'title' => 'Contract Probe', 'description' => 'E2E full-surface contract probe', 'status' => 'active', ]; } /** * @return list */ private function allowedContractStatuses(string $method): array { return match ($method) { 'POST' => [200, 201, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], 'PUT', 'PATCH' => [200, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], 'DELETE' => [200, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422], default => [200, 204, 302, 304, 400, 401, 403, 404, 405, 409, 419, 422], }; } }