170 lines
6.4 KiB
PHP
170 lines
6.4 KiB
PHP
<?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;
|
|
}
|
|
}
|