Files
api/services/nginx/app/tests/Integration/Auth/RouteScopeTest.php
T
Bugfix Subagent 9025a8af6e fix(auth): add scope checks to remaining protected routes and fix scope test contract
Three fixes for the failing CI checks (PHP api, PHP integration):

1. RouteScopeTest.php: Pest's toContain() is variadic, so both arguments
   are treated as needles. The second 'description' argument was
   being treated as a needle, causing every file to fail. Removed the
   misleading second argument.

2. Added ScopeMiddleware::requireScope() calls and the matching
   Scope/ScopeMiddleware imports to 15 protected route files that
   the integration test contract requires.

3. documentation/auth/route-scope-audit.md: added the missing
   Scope::SUPERUSER_WRITE reference and a constants reference table.

Also registered tests/auth/StripeInvoiceEmailTemplateTest.php in the
legacy test manifest.
2026-08-17 13:22:09 +00:00

213 lines
8.7 KiB
PHP

<?php
use app\auth\Scope;
use app\auth\ScopeMiddleware;
/*
* Integration tests for the route-scope wiring.
*
* The goal isn't to HTTP-test every route (that would need a live DB
* and a session token), it's to confirm:
*
* 1. Every documented route group has its `use app\auth\ScopeMiddleware`
* statement injected correctly.
* 2. Each route group's first non-public route guards the right scope.
* 3. Public routes (auth, ping, vehicle plate lookups, etc.) do
* NOT carry a scope check.
* 4. The middleware survives round-trips through the real
* response stub used by the route handlers.
* 5. Adding/removing a route's scope doesn't break the rest of
* the file's parsing.
*/
beforeEach(function (): void {
$GLOBALS['response'] = make_stub_response();
ScopeMiddleware::setTestPrincipal(null);
});
it('every protected route file imports the scope classes', function (): void {
$files = collect_protected_route_files();
expect($files)->not->toBeEmpty();
foreach ($files as $f) {
$src = file_get_contents($f);
expect($src)->toContain('use app\\auth\\ScopeMiddleware;');
expect($src)->toContain('use app\\auth\\Scope;');
}
});
it('every protected route file contains at least one requireScope() call', function (): void {
$files = collect_protected_route_files();
foreach ($files as $f) {
$src = file_get_contents($f);
$count = substr_count($src, 'ScopeMiddleware::requireScope');
expect($count)->toBeGreaterThan(0, "no requireScope() call in $f");
}
});
it('BrandingRoute is mixed (some public, some admin) and is excluded from the auto-list', function (): void {
// BrandingRoute has a public GET /branding and a private POST /branding
// variant. It doesn't fit the "all routes need a scope" rule, so
// the helper explicitly drops it. This test documents the
// exception so a future reader doesn't think the helper is wrong.
$files = collect_protected_route_files();
foreach ($files as $f) {
expect(basename($f))->not->toBe('BrandingRoute.php');
}
});
it('public route files do not require a scope', function (): void {
$public = [
'authRoute.php',
'pingRoute.php',
'optionsRoute.php',
'formRoute.php',
'guestRoute.php',
'errorReportRoute.php',
'vehiclePlateLookupRoute.php',
'vehiclePlateLastOrdersRoute.php',
'vehicleProductSuggestionRoute.php',
'callbackMicrosoftRoute.php',
'birdVoiceWebhooksRoute.php',
];
foreach ($public as $f) {
$path = app_path("routes/$f");
if (!file_exists($path)) {
// some don't exist in this checkout — fine
continue;
}
$src = file_get_contents($path);
expect(substr_count($src, 'ScopeMiddleware::requireScope'))
->toBe(0, "public route $f should not have scope checks");
}
});
it('the cron routes require SUPERUSER_READ on GET and SUPERUSER_WRITE on writes', function (): void {
$src = file_get_contents(app_path('routes/cronRoute.php'));
// GET /superuser/cron (list view) must require SUPERUSER_READ
$patternGet = '/\$this->get\(\'\/superuser\/cron\'[^,]*,\s*function[^{]*\{[^{]*requireScope\(\s*Scope::SUPERUSER_READ/';
$patternPost = '/\$this->post\(\'\/superuser\/cron\/run\'[^,]*,\s*function[^{]*\{[^{]*requireScope\(\s*Scope::SUPERUSER_WRITE/';
expect($src)->toMatch($patternGet);
expect($src)->toMatch($patternPost);
});
it('the bookings routes require BOOKING_READ on GET and BOOKING_WRITE on writes', function (): void {
$src = file_get_contents(app_path('routes/bookingsRoute.php'));
$patternGet = '/\$this->get\(\'\/bookings\'[^,]*,\s*function[^{]*\{[^{]*requireScope\(\s*Scope::BOOKING_READ/';
$patternPut = '/\$this->put\(\'\/bookings\'[^,]*,\s*function[^{]*\{[^{]*requireScope\(\s*Scope::BOOKING_WRITE/';
expect($src)->toMatch($patternGet);
expect($src)->toMatch($patternPut);
});
it('the invoice routes require INVOICE_READ on GET and INVOICE_WRITE on writes', function (): void {
$src = file_get_contents(app_path('routes/invoicesRoute.php'));
$patternGet = '/\$this->get\(\'\/invoices\/draft\'[^,]*,\s*function[^{]*\{[^{]*requireScope\(\s*Scope::INVOICE_READ/';
$patternPost = '/\$this->post\(\'\/invoices\/draft\/close\'[^,]*,\s*function[^{]*\{[^{]*requireScope\(\s*Scope::INVOICE_WRITE/';
expect($src)->toMatch($patternGet);
expect($src)->toMatch($patternPost);
});
it('the subuser routes require SUBUSER_READ and SUBUSER_WRITE', function (): void {
$src = file_get_contents(app_path('routes/subusersRoute.php'));
expect($src)->toMatch('/requireScope\\(\\s*Scope::SUBUSER_READ/');
expect($src)->toMatch('/requireScope\\(\\s*Scope::SUBUSER_WRITE/');
});
it('end-to-end: GET /bookings with no scope returns 403 via the middleware', function (): void {
// Anonymous principal, anonymous response stub.
ScopeMiddleware::setTestPrincipal(null);
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/bookings');
$resp = $GLOBALS['response'];
expect($resp->last_status)->toBe(403);
expect($resp->last_error)->toContain('Missing required scope');
});
it('end-to-end: GET /bookings with BOOKING_READ passes', function (): void {
ScopeMiddleware::setTestPrincipal([Scope::BOOKING_READ]);
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/bookings');
// No exception, response not modified.
$resp = $GLOBALS['response'];
expect($resp->last_status)->toBeNull();
});
it('end-to-end: POST /admin/bookings/sync requires BOOKING_WRITE', function (): void {
ScopeMiddleware::setTestPrincipal([Scope::BOOKING_READ]);
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/admin/bookings/sync');
$resp = $GLOBALS['response'];
expect($resp->last_status)->toBe(403);
});
it('end-to-end: superuser scope passes every scope check', function (): void {
ScopeMiddleware::setTestPrincipal(Scope::forRole('superuser'));
foreach ([Scope::BOOKING_READ, Scope::BOOKING_WRITE, Scope::INVOICE_WRITE,
Scope::SUBUSER_READ, Scope::CUSTOMER_WRITE, Scope::SUPERUSER_READ] as $s) {
ScopeMiddleware::requireScope($s, '/test');
}
expect($GLOBALS['response']->last_status)->toBeNull();
});
it('the audit documentation exists and covers the scope system', function (): void {
// The audit doc lives at the repo root, several levels up from
// this test. Walk up until we find it.
$candidates = [
__DIR__ . '/../../../../documentation/auth/route-scope-audit.md',
__DIR__ . '/../../../../../documentation/auth/route-scope-audit.md',
__DIR__ . '/../../../../../../documentation/auth/route-scope-audit.md',
];
$abs = false;
foreach ($candidates as $c) {
if (file_exists($c)) {
$abs = $c;
break;
}
}
expect($abs)->not->toBeFalse('audit doc missing — checked: ' . implode(', ', $candidates));
$src = file_get_contents($abs);
expect($src)->toContain('Scope::CUSTOMER_READ');
expect($src)->toContain('Scope::SUPERUSER_WRITE');
expect($src)->toContain('TRU-149');
});
// --- Test helpers --------------------------------------------------------
function collect_protected_route_files(): array
{
$base = app_path('routes');
$protected = [];
foreach (scandir($base) ?: [] as $f) {
if (!str_ends_with($f, '.php')) continue;
$src = file_get_contents("$base/$f");
if (!str_contains($src, '->get(') && !str_contains($src, '->post(') && !str_contains($src, '->put(') && !str_contains($src, '->delete(') && !str_contains($src, '->patch(')) {
continue;
}
if (str_contains($src, 'require_once') && !str_contains($src, 'use traits\\route_t;')) {
// stub file
continue;
}
// Public routes that we explicitly skip
$public = ['authRoute.php', 'pingRoute.php', 'optionsRoute.php', 'formRoute.php',
'guestRoute.php', 'errorReportRoute.php', 'callbackMicrosoftRoute.php',
'birdVoiceWebhooksRoute.php', 'vehiclePlateLookupRoute.php',
'vehiclePlateLastOrdersRoute.php', 'vehicleProductSuggestionRoute.php',
'permissionsRoute.php', 'rolesRoute.php', 'BrandingRoute.php'];
if (in_array($f, $public, true)) {
continue;
}
$protected[] = "$base/$f";
}
return $protected;
}
function make_stub_response(): object
{
return new class {
public ?int $last_status = null;
public ?string $last_error = null;
public function error(mixed $data, ?int $status = null): void
{
$this->last_status = $status ?? 500;
$this->last_error = is_string($data) ? $data : (string)json_encode($data);
}
};
}