add test batches
This commit is contained in:
@@ -13,6 +13,7 @@ class ApiAuthenticationAndAuthorizationTest extends TestCase
|
||||
use CreatesApiTestUsers;
|
||||
use RefreshDatabase;
|
||||
|
||||
|
||||
public function test_api_login_returns_token_and_user_object(): void
|
||||
{
|
||||
$user = $this->createApiUserWithRole('teacher', [
|
||||
@@ -102,7 +103,7 @@ class ApiAuthenticationAndAuthorizationTest extends TestCase
|
||||
|
||||
foreach ($this->sampleProtectedRoutes() as $route) {
|
||||
$method = $this->primaryMethod($route);
|
||||
$uri = '/'.$this->uriWithPlaceholders($route->uri());
|
||||
$uri = '/' . $this->uriWithPlaceholders($route->uri());
|
||||
|
||||
$response = $this->json($method, $uri);
|
||||
$status = $response->getStatusCode();
|
||||
@@ -112,7 +113,7 @@ class ApiAuthenticationAndAuthorizationTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $failures, "Anonymous requests reached unexpected responses:\n".implode("\n", $failures));
|
||||
$this->assertSame([], $failures, "Anonymous requests reached unexpected responses:\n" . implode("\n", $failures));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,7 @@ class ApiPreferencesFeatureTest extends TestCase
|
||||
->assertJsonPath('status', true);
|
||||
}
|
||||
|
||||
|
||||
public function test_non_admin_cannot_list_or_manage_another_users_preferences(): void
|
||||
{
|
||||
$owner = $this->createApiUserWithRole('teacher');
|
||||
|
||||
@@ -36,7 +36,7 @@ class ApiPublicEndpointsTest extends TestCase
|
||||
public function test_public_frontend_content_endpoints_do_not_server_error(): void
|
||||
{
|
||||
foreach (['/', 'facility', 'team', 'call-to-action', 'testimonial', 'not-found'] as $path) {
|
||||
$uri = '/api/v1/frontend'.($path === '/' ? '' : '/'.$path);
|
||||
$uri = '/api/v1/frontend' . ($path === '/' ? '' : '/' . $path);
|
||||
$response = $this->getJson($uri);
|
||||
|
||||
$this->assertLessThan(500, $response->getStatusCode(), "$uri returned a server error");
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Concerns\CreatesApiTestUsers;
|
||||
|
||||
@@ -24,11 +24,11 @@ class ApiRouteContractTest extends TestCase
|
||||
|
||||
[$class, $method] = explode('@', $action, 2);
|
||||
if (! class_exists($class) || ! method_exists($class, $method)) {
|
||||
$missing[] = implode('|', $route->methods()).' '.$route->uri().' -> '.$action;
|
||||
$missing[] = implode('|', $route->methods()) . ' ' . $route->uri() . ' -> ' . $action;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $missing, "These API routes reference missing controllers or methods:\n".implode("\n", $missing));
|
||||
$this->assertSame([], $missing, "These API routes reference missing controllers or methods:\n" . implode("\n", $missing));
|
||||
}
|
||||
|
||||
public function test_api_routes_do_not_duplicate_method_and_uri_pairs(): void
|
||||
@@ -42,7 +42,7 @@ class ApiRouteContractTest extends TestCase
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $method.' '.$route->uri();
|
||||
$key = $method . ' ' . $route->uri();
|
||||
if (isset($seen[$key])) {
|
||||
$duplicates[] = $key;
|
||||
}
|
||||
@@ -90,11 +90,11 @@ class ApiRouteContractTest extends TestCase
|
||||
});
|
||||
|
||||
if (! $hasProtection) {
|
||||
$unprotected[] = implode('|', $methods).' '.$route->uri();
|
||||
$unprotected[] = implode('|', $methods) . ' ' . $route->uri();
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $unprotected, "Mutating API routes without an obvious auth/throttle middleware:\n".implode("\n", $unprotected));
|
||||
$this->assertSame([], $unprotected, "Mutating API routes without an obvious auth/throttle middleware:\n" . implode("\n", $unprotected));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Routing\Route as LaravelRoute;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Route-to-use-case coverage guard.
|
||||
*
|
||||
* This does not replace workflow tests. It prevents the slower rot where a new
|
||||
* endpoint is added with no explicit business workflow owner, because apparently
|
||||
* endpoints reproduce when nobody is watching them.
|
||||
*/
|
||||
class ApiUseCaseCoverageMatrixTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_every_api_route_is_mapped_to_an_explicit_use_case_domain(): void
|
||||
{
|
||||
$unmapped = [];
|
||||
|
||||
foreach ($this->apiRoutes() as $route) {
|
||||
$uri = $route->uri();
|
||||
|
||||
if ($this->domainFor($uri) === null) {
|
||||
$unmapped[] = implode('|', array_diff($route->methods(), ['HEAD'])) . ' ' . $uri;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$unmapped,
|
||||
"API routes without an explicit use-case domain:\n" . implode("\n", $unmapped) .
|
||||
"\n\nAdd the route to the matrix and then add/extend an E2E workflow test for that domain."
|
||||
);
|
||||
}
|
||||
|
||||
public function test_required_use_case_domains_have_route_coverage(): void
|
||||
{
|
||||
$domains = collect($this->apiRoutes())
|
||||
->map(fn (LaravelRoute $route): ?string => $this->domainFor($route->uri()))
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$missing = array_values(array_diff(array_keys($this->useCaseDomains()), $domains));
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$missing,
|
||||
"Required use-case domains with no matching API route coverage:\n" . implode("\n", $missing)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_every_protected_api_route_rejects_anonymous_requests(): void
|
||||
{
|
||||
$failures = [];
|
||||
|
||||
foreach ($this->protectedRoutes() as $route) {
|
||||
$method = $this->primaryMethod($route);
|
||||
$uri = '/' . $this->uriWithSafePlaceholders($route->uri());
|
||||
|
||||
$response = $this->json($method, $uri);
|
||||
$status = $response->getStatusCode();
|
||||
|
||||
if (! in_array($status, [401, 403, 404, 405, 419, 422], true)) {
|
||||
$failures[] = "$method $uri returned $status";
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$failures,
|
||||
"Anonymous requests reached protected API routes unexpectedly:\n" . implode("\n", $failures)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function useCaseDomains(): array
|
||||
{
|
||||
return [
|
||||
'public_content_and_support' => '#^api/(?:v1/)?(?:login|register|auth|documentation|docs|docs/public|policies|pages|contact|frontend|health|system/db-check|access_denied|certificates/verify|timeoff/notify|winners/competitions|confirm_authorized_user|set_authorized_user_password|badge_scan|scanner)(?:/|$)#',
|
||||
'legacy_compatibility' => '#^api/(?:proofread|emails|compare|attendance-templates|attendance-comment-templates|administrator/attendance-templates)(?:/|$)#',
|
||||
'preferences_navigation_and_ui' => '#^api/v1/(?:preferences|nav-builder|landing|info-icon|dashboard|utilities|ui|settings|configuration|system|stats|role-switcher)(?:/|$)#',
|
||||
'administration_enrollment_and_school_years' => '#^api/v1/(?:administrator|school-years|class-sections|users|role-permissions|ip-bans|notifications)(?:/|$)#',
|
||||
'students_parents_and_families' => '#^api/v1/(?:students|parents|families|family-admin|emergency-contacts)(?:/|$)#',
|
||||
'teachers_staff_and_class_operations' => '#^api/v1/(?:teacher|teachers|staff|assignments|class-prep|class-progress)(?:/|$)#',
|
||||
'attendance_and_safety' => '#^api/v1/(?:attendance|attendance-tracking|attendance-comment-templates|attendance-templates|incidents)(?:/|$)#',
|
||||
'grading_scores_and_reporting' => '#^api/v1/(?:scores|grading|reports|badges|certificates|print-requests)(?:/|$)#',
|
||||
'finance_billing_and_inventory' => '#^api/v1/(?:finance|discounts|expenses|extra-charges|inventory|files)(?:/|$)#',
|
||||
'communications_and_messaging' => '#^api/v1/(?:messages|broadcast-email|email|email-extractor|communications|support|whatsapp)(?:/|$)#',
|
||||
'competition_and_public_results' => '#^api/v1/(?:competition-scores)(?:/|$)#',
|
||||
];
|
||||
}
|
||||
|
||||
private function domainFor(string $uri): ?string
|
||||
{
|
||||
foreach ($this->useCaseDomains() as $domain => $pattern) {
|
||||
if (preg_match($pattern, $uri) === 1) {
|
||||
return $domain;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<LaravelRoute>
|
||||
*/
|
||||
private function apiRoutes(): array
|
||||
{
|
||||
return array_values(array_filter(Route::getRoutes()->getRoutes(), function (LaravelRoute $route): bool {
|
||||
return str_starts_with($route->uri(), 'api/');
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<LaravelRoute>
|
||||
*/
|
||||
private function protectedRoutes(): array
|
||||
{
|
||||
return array_values(array_filter($this->apiRoutes(), function (LaravelRoute $route): bool {
|
||||
if ($this->isStreamOrGeneratedFileRoute($route->uri())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return collect($route->gatherMiddleware())->contains(function (string $middleware): bool {
|
||||
return str_contains($middleware, 'auth:api')
|
||||
|| str_contains($middleware, 'jwt.auth')
|
||||
|| str_contains($middleware, 'admin.access')
|
||||
|| str_contains($middleware, 'parent.access')
|
||||
|| str_contains($middleware, 'teacher.portal.auth');
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
private function isStreamOrGeneratedFileRoute(string $uri): bool
|
||||
{
|
||||
return str_contains($uri, 'files/')
|
||||
|| str_contains($uri, 'receipts/')
|
||||
|| str_contains($uri, 'reimbursements/')
|
||||
|| str_contains($uri, 'attachments/')
|
||||
|| str_contains($uri, '/pdf')
|
||||
|| str_contains($uri, '/csv');
|
||||
}
|
||||
|
||||
private function primaryMethod(LaravelRoute $route): string
|
||||
{
|
||||
foreach ($route->methods() as $method) {
|
||||
if (! in_array($method, ['HEAD', 'OPTIONS'], true)) {
|
||||
return $method;
|
||||
}
|
||||
}
|
||||
|
||||
return 'GET';
|
||||
}
|
||||
|
||||
private function uriWithSafePlaceholders(string $uri): string
|
||||
{
|
||||
return preg_replace('/\{[^}]+\}/', '1', $uri) ?? $uri;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
@@ -121,4 +121,4 @@ class AdministratorAbsenceControllerTest extends TestCase
|
||||
'dates' => ['2026-03-08'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,4 +128,4 @@ class AdministratorEnrollmentControllerTest extends TestCase
|
||||
'message' => 'Enrollment statuses updated and notifications sent.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,7 +263,6 @@ class AdministratorEnrollmentUpdateStatusesApiTest extends TestCase
|
||||
private function postUpdateStatuses(array $enrollmentStatus)
|
||||
{
|
||||
$query = http_build_query(['enrollment_status' => $enrollmentStatus]);
|
||||
|
||||
return $this->post('/api/v1/administrator/enrollment-withdrawal/update-statuses?'.$query, []);
|
||||
return $this->post('/api/v1/administrator/enrollment-withdrawal/update-statuses?' . $query, []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,4 +157,4 @@ class AdministratorNotificationControllerTest extends TestCase
|
||||
'message' => 'Print notification recipients updated.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace Tests\Feature\Api\Administrator;
|
||||
|
||||
use App\Http\Middleware\EnsurePrintRequestsAdminAccess;
|
||||
use App\Models\User;
|
||||
use App\Services\Administrator\TeacherSubmissionNotificationService;
|
||||
use App\Services\Administrator\TeacherSubmissionReportService;
|
||||
use App\Http\Middleware\EnsurePrintRequestsAdminAccess;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Mockery;
|
||||
|
||||
@@ -281,7 +281,7 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
|
||||
'class_section_id' => 12,
|
||||
'admin_id' => 901,
|
||||
'notification_category' => 'teacher_submissions',
|
||||
'message' => 'Reminder '.$i,
|
||||
'message' => 'Reminder ' . $i,
|
||||
'status' => 'sent',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
|
||||
@@ -80,7 +80,7 @@ class EmergencyContactControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($admin, [], 'api');
|
||||
$response = $this->patch('/api/v1/administrator/emergency-contacts/'.$contact->id, [
|
||||
$response = $this->patch('/api/v1/administrator/emergency-contacts/' . $contact->id, [
|
||||
'parent_id' => $parent->id,
|
||||
'name' => 'New Name',
|
||||
'cellphone' => '5555555555',
|
||||
@@ -116,7 +116,7 @@ class EmergencyContactControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($admin, [], 'api');
|
||||
$response = $this->delete('/api/v1/administrator/emergency-contacts/'.$contact->id, [
|
||||
$response = $this->delete('/api/v1/administrator/emergency-contacts/' . $contact->id, [
|
||||
'parent_id' => $parent->id,
|
||||
], [
|
||||
'Accept' => 'application/json',
|
||||
@@ -145,7 +145,7 @@ class EmergencyContactControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($admin, [], 'api');
|
||||
$response = $this->get('/api/v1/administrator/emergency-contacts/'.$contact->id.'?parent_id='.$parent->id, [
|
||||
$response = $this->get('/api/v1/administrator/emergency-contacts/' . $contact->id . '?parent_id=' . $parent->id, [
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
|
||||
@@ -171,7 +171,7 @@ class EmergencyContactControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($admin, [], 'api');
|
||||
$response = $this->patch('/api/v1/administrator/emergency-contacts/'.$contact->id, [
|
||||
$response = $this->patch('/api/v1/administrator/emergency-contacts/' . $contact->id, [
|
||||
'parent_id' => $otherParent->id,
|
||||
'name' => 'New Name',
|
||||
'cellphone' => '9999999999',
|
||||
@@ -205,7 +205,7 @@ class EmergencyContactControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($admin, [], 'api');
|
||||
$response = $this->delete('/api/v1/administrator/emergency-contacts/'.$contact->id, [
|
||||
$response = $this->delete('/api/v1/administrator/emergency-contacts/' . $contact->id, [
|
||||
'parent_id' => $otherParent->id,
|
||||
], [
|
||||
'Accept' => 'application/json',
|
||||
|
||||
@@ -16,17 +16,11 @@ class AssignmentApiControllerTest extends TestCase
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $user;
|
||||
|
||||
protected User $teacherMain;
|
||||
|
||||
protected User $teacherAssistant;
|
||||
|
||||
protected ClassSection $sectionA;
|
||||
|
||||
protected ClassSection $sectionB;
|
||||
|
||||
protected Student $studentOne;
|
||||
|
||||
protected Student $studentTwo;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -424,4 +418,4 @@ class AssignmentApiControllerTest extends TestCase
|
||||
|
||||
$this->assertSame(['Main Teacher'], $mainTeachers);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\User;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
@@ -48,36 +48,36 @@ class AssignmentApiTest extends TestCase
|
||||
|
||||
$teacher = User::factory()->create([
|
||||
'firstname' => 'Ahmad',
|
||||
'lastname' => 'Ali',
|
||||
'lastname' => 'Ali',
|
||||
]);
|
||||
|
||||
TeacherClass::query()->create([
|
||||
'teacher_id' => $teacher->id,
|
||||
'class_section_id' => $section->class_section_id,
|
||||
'position' => 'main',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'description' => 'Main teacher assignment',
|
||||
'teacher_id' => $teacher->id,
|
||||
'class_section_id' => $section->class_section_id,
|
||||
'position' => 'main',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'description' => 'Main teacher assignment',
|
||||
]);
|
||||
|
||||
$student = Student::factory()->create([
|
||||
'firstname' => 'Sara',
|
||||
'lastname' => 'Yousef',
|
||||
'is_active' => 1,
|
||||
'gender' => 'F',
|
||||
'firstname' => 'Sara',
|
||||
'lastname' => 'Yousef',
|
||||
'is_active' => 1,
|
||||
'gender' => 'F',
|
||||
'registration_grade' => '5',
|
||||
'photo_consent' => 1,
|
||||
'tuition_paid' => 1,
|
||||
'school_id' => 'SCH-1001',
|
||||
'photo_consent' => 1,
|
||||
'tuition_paid' => 1,
|
||||
'school_id' => 'SCH-1001',
|
||||
]);
|
||||
|
||||
StudentClass::query()->create([
|
||||
'student_id' => $student->id,
|
||||
'class_section_id' => $section->class_section_id,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'description' => 'Student assigned',
|
||||
'is_active' => 1, // remove if your table doesn’t have this column
|
||||
'student_id' => $student->id,
|
||||
'class_section_id' => $section->class_section_id,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'description' => 'Student assigned',
|
||||
'is_active' => 1, // remove if your table doesn’t have this column
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/assignments');
|
||||
@@ -96,7 +96,7 @@ class AssignmentApiTest extends TestCase
|
||||
'semester',
|
||||
'school_year',
|
||||
'description',
|
||||
],
|
||||
]
|
||||
],
|
||||
'schoolYears',
|
||||
'selectedYear',
|
||||
@@ -122,19 +122,19 @@ class AssignmentApiTest extends TestCase
|
||||
$teacher = User::factory()->create(['firstname' => 'Test', 'lastname' => 'Teacher']);
|
||||
|
||||
TeacherClass::query()->create([
|
||||
'teacher_id' => $teacher->id,
|
||||
'teacher_id' => $teacher->id,
|
||||
'class_section_id' => $section1->class_section_id,
|
||||
'position' => 'main',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2024-2025',
|
||||
'position' => 'main',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2024-2025',
|
||||
]);
|
||||
|
||||
TeacherClass::query()->create([
|
||||
'teacher_id' => $teacher->id,
|
||||
'teacher_id' => $teacher->id,
|
||||
'class_section_id' => $section2->class_section_id,
|
||||
'position' => 'main',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'position' => 'main',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/assignments?school_year=2025-2026');
|
||||
@@ -158,11 +158,11 @@ class AssignmentApiTest extends TestCase
|
||||
$section = ClassSection::factory()->create();
|
||||
|
||||
$payload = [
|
||||
'student_id' => $student->id,
|
||||
'student_id' => $student->id,
|
||||
'class_section_id' => $section->class_section_id,
|
||||
'semester' => 'Spring',
|
||||
'school_year' => '2025-2026',
|
||||
'description' => 'Moved after placement review',
|
||||
'semester' => 'Spring',
|
||||
'school_year' => '2025-2026',
|
||||
'description' => 'Moved after placement review',
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/assignments', $payload);
|
||||
@@ -171,10 +171,10 @@ class AssignmentApiTest extends TestCase
|
||||
->assertJsonPath('message', 'Assignment saved successfully.');
|
||||
|
||||
$this->assertDatabaseHas('student_class', [
|
||||
'student_id' => $student->id,
|
||||
'student_id' => $student->id,
|
||||
'class_section_id' => $section->class_section_id,
|
||||
'semester' => 'Spring',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Spring',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -203,19 +203,19 @@ class AssignmentApiTest extends TestCase
|
||||
|
||||
// First create
|
||||
$this->postJson('/api/assignments', [
|
||||
'student_id' => $student->id,
|
||||
'student_id' => $student->id,
|
||||
'class_section_id' => $sectionA->class_section_id,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
])->assertCreated();
|
||||
|
||||
// Second call same unique key should update (per service updateOrCreate)
|
||||
$this->postJson('/api/assignments', [
|
||||
'student_id' => $student->id,
|
||||
'student_id' => $student->id,
|
||||
'class_section_id' => $sectionA->class_section_id,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'description' => 'Reassigned',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'description' => 'Reassigned',
|
||||
])->assertCreated();
|
||||
|
||||
$this->assertEquals(
|
||||
@@ -228,10 +228,10 @@ class AssignmentApiTest extends TestCase
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('student_class', [
|
||||
'student_id' => $student->id,
|
||||
'student_id' => $student->id,
|
||||
'class_section_id' => $sectionA->class_section_id,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ class LateSlipLogsControllerTest extends TestCase
|
||||
$user = User::query()->create([
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
'email' => $roleName.'@example.com',
|
||||
'email' => $roleName . '@example.com',
|
||||
'cellphone' => '5555555555',
|
||||
'address_street' => '123 Main',
|
||||
'city' => 'City',
|
||||
|
||||
@@ -85,10 +85,10 @@ class AttendanceCommentTemplateApiTest extends TestCase
|
||||
$mock->shouldReceive('create')
|
||||
->once()
|
||||
->with(Mockery::on(function ($data) use ($payload) {
|
||||
return (int) $data['min_score'] === 80
|
||||
&& (int) $data['max_score'] === 100
|
||||
return (int)$data['min_score'] === 80
|
||||
&& (int)$data['max_score'] === 100
|
||||
&& $data['template_text'] === $payload['template_text']
|
||||
&& (bool) $data['is_active'] === true;
|
||||
&& (bool)$data['is_active'] === true;
|
||||
}))
|
||||
->andReturn([
|
||||
'id' => 10,
|
||||
@@ -159,7 +159,7 @@ class AttendanceCommentTemplateApiTest extends TestCase
|
||||
->once()
|
||||
->with(7, Mockery::on(function ($data) {
|
||||
return $data['template_text'] === 'Updated comment text'
|
||||
&& (bool) $data['is_active'] === false;
|
||||
&& (bool)$data['is_active'] === false;
|
||||
}))
|
||||
->andReturn([
|
||||
'id' => 7,
|
||||
@@ -221,7 +221,7 @@ class AttendanceCommentTemplateApiTest extends TestCase
|
||||
'max_score' => 49,
|
||||
'template_text' => 'Low attendance',
|
||||
'is_active' => true,
|
||||
],
|
||||
]
|
||||
]);
|
||||
|
||||
$this->app->instance(AttendanceCommentTemplateService::class, $mock);
|
||||
@@ -232,4 +232,4 @@ class AttendanceCommentTemplateApiTest extends TestCase
|
||||
->assertJsonCount(1, 'templates')
|
||||
->assertJsonPath('templates.0.id', 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace Tests\Feature\Api\V1\AttendanceTracking;
|
||||
|
||||
use App\Services\AttendanceTracking\AttendanceTrackingService;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AttendanceTrackingControllerTest extends TestCase
|
||||
@@ -15,7 +14,7 @@ class AttendanceTrackingControllerTest extends TestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
protected function mockService(): MockInterface
|
||||
protected function mockService(): \Mockery\MockInterface
|
||||
{
|
||||
$service = Mockery::mock(AttendanceTrackingService::class);
|
||||
$this->app->instance(AttendanceTrackingService::class, $service);
|
||||
|
||||
@@ -159,7 +159,7 @@ class IpBanControllerTest extends TestCase
|
||||
$user = User::query()->create([
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
'email' => $roleName.'@example.com',
|
||||
'email' => $roleName . '@example.com',
|
||||
'cellphone' => '5555555555',
|
||||
'address_street' => '123 Main',
|
||||
'city' => 'City',
|
||||
|
||||
@@ -234,4 +234,4 @@ class BadgeControllerTest extends TestCase
|
||||
$this->postJson('/api/badges/log-print', ['user_ids' => [1]])->assertStatus(401);
|
||||
$this->postJson('/api/badges/pdf', ['user_ids' => [1]])->assertStatus(401);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,7 @@ class BroadcastEmailControllerTest extends TestCase
|
||||
{
|
||||
$this->seedParentData();
|
||||
|
||||
$fake = new class extends EmailService
|
||||
{
|
||||
$fake = new class extends EmailService {
|
||||
public array $sent = [];
|
||||
|
||||
public function send(
|
||||
@@ -42,9 +41,9 @@ class BroadcastEmailControllerTest extends TestCase
|
||||
string $fromKey = 'general',
|
||||
array $cc = [],
|
||||
array $bcc = []
|
||||
): bool {
|
||||
): bool
|
||||
{
|
||||
$this->sent[] = compact('to', 'subject', 'html', 'fromKey', 'cc', 'bcc');
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@ class ClassProgressControllerTest extends TestCase
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->getJson('/api/v1/class-progress/'.$report->id);
|
||||
$response = $this->getJson('/api/v1/class-progress/' . $report->id);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('status', true);
|
||||
@@ -110,7 +110,7 @@ class ClassProgressControllerTest extends TestCase
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->patchJson('/api/v1/class-progress/'.$report->id, [
|
||||
$response = $this->patchJson('/api/v1/class-progress/' . $report->id, [
|
||||
'status' => 'behind',
|
||||
]);
|
||||
|
||||
@@ -132,7 +132,7 @@ class ClassProgressControllerTest extends TestCase
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->deleteJson('/api/v1/class-progress/'.$report->id);
|
||||
$response = $this->deleteJson('/api/v1/class-progress/' . $report->id);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseMissing('class_progress_reports', [
|
||||
@@ -152,7 +152,7 @@ class ClassProgressControllerTest extends TestCase
|
||||
|
||||
Sanctum::actingAs($other);
|
||||
|
||||
$response = $this->getJson('/api/v1/class-progress/'.$report->id);
|
||||
$response = $this->getJson('/api/v1/class-progress/' . $report->id);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
@@ -187,7 +187,7 @@ class ClassProgressControllerTest extends TestCase
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->get('/api/v1/class-progress/attachments/'.$attachment->id);
|
||||
$response = $this->get('/api/v1/class-progress/attachments/' . $attachment->id);
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ class ClassSectionControllerTest extends TestCase
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
'cellphone' => '5555555555',
|
||||
'email' => $roleName.'@example.com',
|
||||
'email' => $roleName . '@example.com',
|
||||
'address_street' => '123 Main',
|
||||
'city' => 'City',
|
||||
'state' => 'ST',
|
||||
|
||||
@@ -50,8 +50,7 @@ class CommunicationControllerTest extends TestCase
|
||||
{
|
||||
$this->seedCommunicationData();
|
||||
|
||||
$fake = new class extends EmailService
|
||||
{
|
||||
$fake = new class extends EmailService {
|
||||
public array $sent = [];
|
||||
|
||||
public function send(
|
||||
@@ -63,7 +62,6 @@ class CommunicationControllerTest extends TestCase
|
||||
array $bcc = []
|
||||
): bool {
|
||||
$this->sent[] = compact('to', 'subject', 'html', 'fromKey', 'cc', 'bcc');
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ class FamilyAdminControllerTest extends TestCase
|
||||
$this->seedFamilyGuardian($familyId, $user->id);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
$response = $this->getJson('/api/v1/family-admin?student_id='.$studentId);
|
||||
$response = $this->getJson('/api/v1/family-admin?student_id=' . $studentId);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('status', true);
|
||||
@@ -58,7 +58,7 @@ class FamilyAdminControllerTest extends TestCase
|
||||
$this->seedPayment($user->id);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
$response = $this->getJson('/api/v1/family-admin/card?family_id='.$familyId);
|
||||
$response = $this->getJson('/api/v1/family-admin/card?family_id=' . $familyId);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('status', true);
|
||||
@@ -123,7 +123,7 @@ class FamilyAdminControllerTest extends TestCase
|
||||
'firstname' => 'Parent',
|
||||
'lastname' => 'User',
|
||||
'cellphone' => '9999999999',
|
||||
'email' => 'parent'.$id.'@example.com',
|
||||
'email' => 'parent' . $id . '@example.com',
|
||||
'address_street' => '123 Street',
|
||||
'city' => 'City',
|
||||
'state' => 'ST',
|
||||
@@ -163,7 +163,7 @@ class FamilyAdminControllerTest extends TestCase
|
||||
{
|
||||
return DB::table('families')->insertGetId([
|
||||
'family_code' => $code,
|
||||
'household_name' => 'Family '.$code,
|
||||
'household_name' => 'Family ' . $code,
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
|
||||
@@ -20,7 +20,7 @@ class FamilyControllerTest extends TestCase
|
||||
$this->seedFamilyStudent($familyId, $studentId);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
$response = $this->getJson('/api/v1/families/by-student/'.$studentId);
|
||||
$response = $this->getJson('/api/v1/families/by-student/' . $studentId);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('status', true);
|
||||
@@ -34,7 +34,7 @@ class FamilyControllerTest extends TestCase
|
||||
$this->seedFamilyGuardian($familyId, $user->id);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
$response = $this->getJson('/api/v1/families/'.$familyId.'/guardians');
|
||||
$response = $this->getJson('/api/v1/families/' . $familyId . '/guardians');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('status', true);
|
||||
@@ -292,7 +292,7 @@ class FamilyControllerTest extends TestCase
|
||||
{
|
||||
return DB::table('families')->insertGetId([
|
||||
'family_code' => $code,
|
||||
'household_name' => 'Family '.$code,
|
||||
'household_name' => 'Family ' . $code,
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
|
||||
@@ -28,7 +28,7 @@ class EventChargeControllerTest extends TestCase
|
||||
{
|
||||
$charge = $this->makeCharge();
|
||||
|
||||
$this->getJson('/api/v1/finance/event-charges/'.$charge->id)
|
||||
$this->getJson('/api/v1/finance/event-charges/' . $charge->id)
|
||||
->assertStatus(401);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class EventChargeControllerTest extends TestCase
|
||||
$this->actingAsApiAdministrator();
|
||||
$charge = $this->makeCharge();
|
||||
|
||||
$this->getJson('/api/v1/finance/event-charges/'.$charge->id)
|
||||
$this->getJson('/api/v1/finance/event-charges/' . $charge->id)
|
||||
->assertOk()
|
||||
->assertJsonPath('ok', true)
|
||||
->assertJsonPath('event_charge.id', $charge->id)
|
||||
@@ -57,7 +57,7 @@ class EventChargeControllerTest extends TestCase
|
||||
$this->actingAsApiAdministrator();
|
||||
$charge = $this->makeCharge();
|
||||
|
||||
$this->putJson('/api/v1/finance/event-charges/'.$charge->id, [
|
||||
$this->putJson('/api/v1/finance/event-charges/' . $charge->id, [
|
||||
'event_name' => 'Museum Visit',
|
||||
'amount' => 40.50,
|
||||
])
|
||||
@@ -76,7 +76,7 @@ class EventChargeControllerTest extends TestCase
|
||||
$this->actingAsApiAdministrator();
|
||||
$charge = $this->makeCharge(['charged' => 0]);
|
||||
|
||||
$this->postJson('/api/v1/finance/event-charges/'.$charge->id.'/approve')
|
||||
$this->postJson('/api/v1/finance/event-charges/' . $charge->id . '/approve')
|
||||
->assertOk()
|
||||
->assertJsonPath('ok', true);
|
||||
|
||||
@@ -91,7 +91,7 @@ class EventChargeControllerTest extends TestCase
|
||||
$this->actingAsApiAdministrator();
|
||||
$charge = $this->makeCharge(['charged' => 1]);
|
||||
|
||||
$this->postJson('/api/v1/finance/event-charges/'.$charge->id.'/void')
|
||||
$this->postJson('/api/v1/finance/event-charges/' . $charge->id . '/void')
|
||||
->assertOk()
|
||||
->assertJsonPath('ok', true);
|
||||
|
||||
@@ -106,7 +106,7 @@ class EventChargeControllerTest extends TestCase
|
||||
$this->actingAsApiAdministrator();
|
||||
$charge = $this->makeCharge(['charged' => 1]);
|
||||
|
||||
$this->deleteJson('/api/v1/finance/event-charges/'.$charge->id)
|
||||
$this->deleteJson('/api/v1/finance/event-charges/' . $charge->id)
|
||||
->assertOk()
|
||||
->assertJsonPath('ok', true);
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class FrontendControllerTest extends TestCase
|
||||
$user = User::query()->create([
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
'email' => $roleName.'@example.com',
|
||||
'email' => $roleName . '@example.com',
|
||||
'cellphone' => '5555555555',
|
||||
'address_street' => '123 Main',
|
||||
'city' => 'City',
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Api\V1\Frontend;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class LandingPageControllerTest extends TestCase
|
||||
$user = User::query()->create([
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
'email' => $roleName.'@example.com',
|
||||
'email' => $roleName . '@example.com',
|
||||
'cellphone' => '5555555555',
|
||||
'address_street' => '123 Main',
|
||||
'city' => 'City',
|
||||
|
||||
@@ -15,7 +15,7 @@ class PageControllerTest extends TestCase
|
||||
{
|
||||
$dir = public_path('html');
|
||||
File::ensureDirectoryExists($dir);
|
||||
File::put($dir.'/privacy_policy.html', '<h1>Privacy</h1>');
|
||||
File::put($dir . '/privacy_policy.html', '<h1>Privacy</h1>');
|
||||
|
||||
$response = $this->getJson('/api/v1/pages/privacy');
|
||||
|
||||
@@ -28,7 +28,7 @@ class PageControllerTest extends TestCase
|
||||
{
|
||||
$dir = public_path('html');
|
||||
File::ensureDirectoryExists($dir);
|
||||
File::delete($dir.'/help_center.html');
|
||||
File::delete($dir . '/help_center.html');
|
||||
|
||||
$response = $this->getJson('/api/v1/pages/help');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user