add test batches
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+48
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+35
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+31
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+35
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+38
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+81
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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');
|
||||
}));
|
||||
}
|
||||
}
|
||||
+95
@@ -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
Reference in New Issue
Block a user