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.
213 lines
8.7 KiB
PHP
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);
|
|
}
|
|
};
|
|
}
|