create(); }); test('a module slug must be well formed', function (string $slug) { $event = new RegisteringApiModules; expect(fn () => $event->register($slug, fn () => null, null)) ->toThrow(InvalidArgumentException::class); })->with([ 'uppercase' => 'Branding', 'underscore' => 'custom_assets', 'path traversal' => '../../etc', 'slash' => 'a/b', 'leading hyphen' => '-branding', 'empty' => '', ]); test('two modules cannot share a slug', function () { $event = new RegisteringApiModules; $event->register('branding', fn () => null, null); expect(fn () => $event->register('branding', fn () => null, null)) ->toThrow(InvalidArgumentException::class); }); test('a module cannot declare a capability core does not know', function () { $event = new RegisteringApiModules; expect(fn () => $event->register('branding', fn () => null, 'invented.capability')) ->toThrow(InvalidArgumentException::class); }); test('a missing route file is rejected at registration', function () { $event = new RegisteringApiModules; expect(fn () => $event->register('branding', '/nonexistent/api-routes.php', null)) ->toThrow(InvalidArgumentException::class); }); test('registered modules are reported for discovery', function () { $event = new RegisteringApiModules; $event->register('branding', fn () => null, 'branding.customize'); $event->register('custom-assets', fn () => null, 'custom_assets.manage'); expect($event->slugs())->toBe(['branding', 'custom-assets']); }); /* * The boundary itself: a package may not claim a path under /api/v1 * except beneath /api/v1/modules. Without this a package could shadow or * extend a core endpoint and inherit its trust. * * Keyed off the controller's namespace rather than a hardcoded list of * core URIs — the packages are the thing being constrained, and a list of * core routes would need editing every time core gains an endpoint, which * makes it a chore that gets rubber-stamped rather than a guard. */ test('no package route escapes the modules prefix', function () { $packageNamespace = 'ProjectSend\\'; $offenders = collect(Route::getRoutes()->getRoutes()) ->filter(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/')) ->reject(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/modules/')) ->filter(function (RouteInstance $route) use ($packageNamespace): bool { $action = $route->getAction('controller') ?? ''; return is_string($action) && str_starts_with($action, $packageNamespace); }) ->map(fn (RouteInstance $route): string => $route->uri()) ->unique() ->values() ->all(); expect($offenders)->toBe([]); }); test('core api routes all carry the staff token stack', function () { // The other half of the same boundary: whatever core adds under // /api/v1, it must not be reachable without a staff token. A new // endpoint declared outside the group in routes/api.php would be. // // The allowlist is a single documented exception rather than a loosened // rule: adding a second unauthenticated endpoint has to be a deliberate // edit here, with a reason next to it. $publicByDesign = [ // The specification itself — a client needs it before it can have a // token, and it describes the API's shape, never this // installation's data (asserted in OpenApiContractTest). 'api/v1/openapi.json', ]; $unguarded = collect(Route::getRoutes()->getRoutes()) ->filter(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/')) ->reject(fn (RouteInstance $route): bool => in_array($route->uri(), $publicByDesign, true)) ->reject(fn (RouteInstance $route): bool => in_array('staff-token', $route->gatherMiddleware(), true)) ->map(fn (RouteInstance $route): string => $route->uri()) ->unique() ->values() ->all(); expect($unguarded)->toBe([]); }); /* * With a real module installed, the edition gate must actually bite. * * Physical absence is the primary control — cloud-modules is simply not * installed on a community deployment — but "present anyway" is the case * that has gone wrong before: CustomAssets once shipped routes that * answered 200 in an edition where the feature was never supposed to * exist, because restating the capability was left to the package. Here * the host applies it from the module's own declaration, so it cannot be * forgotten. * * Skipped where no module is installed, which includes CI. */ test('a module endpoint is refused in an edition without its capability', function () { $moduleRoute = collect(Route::getRoutes()->getRoutes()) ->first(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/modules/')); if ($moduleRoute === null) { expect(true)->toBeTrue(); return; } $staff = User::factory()->create(); $token = $staff->createToken('t', ['edit_settings'])->plainTextToken; // Branding is cloud-only; the suite runs as community by default. $this->withToken($token)->getJson('/'.$moduleRoute->uri())->assertForbidden(); config(['projectsend.edition' => Edition::Cloud]); forgetRequestState(); $this->withToken($token)->getJson('/'.$moduleRoute->uri())->assertOk(); }); test('every module route inherits the core auth stack', function () { $moduleRoutes = collect(Route::getRoutes()->getRoutes()) ->filter(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/modules/')); if ($moduleRoutes->isEmpty()) { // Neither package is installed in this checkout. Skipping is // correct — the invariant is about installed modules, and a // failure here would just mean "no packages present". expect(true)->toBeTrue(); return; } $moduleRoutes->each(function (RouteInstance $route): void { $middleware = $route->gatherMiddleware(); expect($middleware)->toContain('auth:sanctum') ->and($middleware)->toContain('staff-token') ->and($middleware)->toContain('api-active'); }); });