config = createStubConfigForUnitTest(debug: false); }); afterEach(function () { Mockery::close(); }); describe('namespace-based access blocking', function () { it('blocks all controllers when namespace is blocked', function () { $namespaceConfig = [ 'App\Application\Admin\*' => [ 'access_policy' => NamespaceAccessPolicy::blocked(), ], ]; $service = new RouteAuthorizationService($this->config, $namespaceConfig); $request = createMockRequest('127.0.0.1'); $routeContext = createMockRouteContext( 'App\Application\Admin\Dashboard', '/admin/dashboard' ); expect(fn () => $service->authorize($request, $routeContext)) ->toThrow(RouteNotFound::class); }); it('allows controllers in allowlist when namespace is blocked', function () { $namespaceConfig = [ 'App\Application\Admin\*' => [ 'access_policy' => NamespaceAccessPolicy::blockedExcept( 'App\Application\Admin\LoginController' ), ], ]; $service = new RouteAuthorizationService($this->config, $namespaceConfig); $request = createMockRequest('127.0.0.1'); // LoginController should be allowed $loginRoute = createMockRouteContext( 'App\Application\Admin\LoginController', '/admin/login' ); expect(fn () => $service->authorize($request, $loginRoute)) ->not->toThrow(RouteNotFound::class); // Dashboard should be blocked $dashboardRoute = createMockRouteContext( 'App\Application\Admin\Dashboard', '/admin/dashboard' ); expect(fn () => $service->authorize($request, $dashboardRoute)) ->toThrow(RouteNotFound::class); }); it('allows all controllers when no access policy is set', function () { $namespaceConfig = [ 'App\Application\Admin\*' => [ 'visibility' => 'public', // No access_policy - should not block ], ]; $service = new RouteAuthorizationService($this->config, $namespaceConfig); $request = createMockRequest('127.0.0.1'); $routeContext = createMockRouteContext( 'App\Application\Admin\Dashboard', '/admin/dashboard' ); expect(fn () => $service->authorize($request, $routeContext)) ->not->toThrow(RouteNotFound::class); }); }); describe('namespace pattern matching', function () { it('matches wildcard patterns', function () { $namespaceConfig = [ 'App\Application\Admin\*' => [ 'access_policy' => NamespaceAccessPolicy::blocked(), ], ]; $service = new RouteAuthorizationService($this->config, $namespaceConfig); $request = createMockRequest('127.0.0.1'); // Should match App\Application\Admin\Dashboard $route1 = createMockRouteContext( 'App\Application\Admin\Dashboard', '/admin/dashboard' ); expect(fn () => $service->authorize($request, $route1)) ->toThrow(RouteNotFound::class); // Should match App\Application\Admin\Users\UserController $route2 = createMockRouteContext( 'App\Application\Admin\Users\UserController', '/admin/users' ); expect(fn () => $service->authorize($request, $route2)) ->toThrow(RouteNotFound::class); // Should NOT match App\Application\Api\UserController $route3 = createMockRouteContext( 'App\Application\Api\UserController', '/api/users' ); expect(fn () => $service->authorize($request, $route3)) ->not->toThrow(RouteNotFound::class); }); it('matches exact namespaces', function () { $namespaceConfig = [ 'App\Application\Admin' => [ 'access_policy' => NamespaceAccessPolicy::blocked(), ], ]; $service = new RouteAuthorizationService($this->config, $namespaceConfig); $request = createMockRequest('127.0.0.1'); // Should match exact namespace $route1 = createMockRouteContext( 'App\Application\Admin\Dashboard', '/admin/dashboard' ); expect(fn () => $service->authorize($request, $route1)) ->toThrow(RouteNotFound::class); }); }); describe('multiple namespace configurations', function () { it('applies first matching namespace config', function () { $namespaceConfig = [ 'App\Application\Admin\*' => [ 'access_policy' => NamespaceAccessPolicy::blocked(), ], 'App\Application\Api\*' => [ 'access_policy' => NamespaceAccessPolicy::blockedExcept( 'App\Application\Api\HealthController' ), ], ]; $service = new RouteAuthorizationService($this->config, $namespaceConfig); $request = createMockRequest('127.0.0.1'); // Admin should be blocked $adminRoute = createMockRouteContext( 'App\Application\Admin\Dashboard', '/admin/dashboard' ); expect(fn () => $service->authorize($request, $adminRoute)) ->toThrow(RouteNotFound::class); // API Health should be allowed $healthRoute = createMockRouteContext( 'App\Application\Api\HealthController', '/api/health' ); expect(fn () => $service->authorize($request, $healthRoute)) ->not->toThrow(RouteNotFound::class); // Other API should be blocked $apiRoute = createMockRouteContext( 'App\Application\Api\UsersController', '/api/users' ); expect(fn () => $service->authorize($request, $apiRoute)) ->toThrow(RouteNotFound::class); }); }); describe('legacy Auth attribute', function () { it('blocks non-wireguard IPs when Auth attribute is present', function () { $service = new RouteAuthorizationService($this->config, []); $request = createMockRequest('192.168.1.100'); $routeContext = createMockRouteContext( 'App\Application\Admin\Dashboard', '/admin/dashboard', attributes: [Auth::class] ); expect(fn () => $service->authorize($request, $routeContext)) ->toThrow(RouteNotFound::class); }); it('allows wireguard IP when Auth attribute is present', function () { $service = new RouteAuthorizationService($this->config, []); $request = createMockRequest('172.20.0.1'); $routeContext = createMockRouteContext( 'App\Application\Admin\Dashboard', '/admin/dashboard', attributes: [Auth::class] ); expect(fn () => $service->authorize($request, $routeContext)) ->not->toThrow(RouteNotFound::class); }); it('skips Auth check in debug mode', function () { $debugConfig = createStubConfigForUnitTest(debug: true); $service = new RouteAuthorizationService($debugConfig, []); $request = createMockRequest('192.168.1.100'); $routeContext = createMockRouteContext( 'App\Application\Admin\Dashboard', '/admin/dashboard', attributes: [Auth::class] ); // Should not throw in debug mode expect(fn () => $service->authorize($request, $routeContext)) ->not->toThrow(RouteNotFound::class); }); }); describe('factory method', function () { it('creates service with namespace config', function () { $namespaceConfig = [ 'App\Application\Admin\*' => [ 'access_policy' => NamespaceAccessPolicy::blocked(), ], ]; $service = RouteAuthorizationService::withNamespaceConfig( $this->config, $namespaceConfig ); expect($service)->toBeInstanceOf(RouteAuthorizationService::class); $request = createMockRequest('127.0.0.1'); $routeContext = createMockRouteContext( 'App\Application\Admin\Dashboard', '/admin/dashboard' ); expect(fn () => $service->authorize($request, $routeContext)) ->toThrow(RouteNotFound::class); }); }); }); // Helper functions for test setup function createStubConfigForUnitTest(bool $debug = false): TypedConfiguration { return new class ($debug) extends TypedConfiguration { public function __construct(bool $debug) { $this->app = (object)['debug' => $debug]; } }; } function createMockRequest(string $ipAddress): Request { $request = Mockery::mock(Request::class); $server = Mockery::mock('server'); $server->shouldReceive('getClientIp') ->andReturn(IpAddress::fromString($ipAddress)); $request->server = $server; return $request; } function createMockRouteContext( string $controllerClass, string $path, array $attributes = [] ): RouteContext { $route = (object)[ 'controller' => $controllerClass, 'action' => 'index', 'attributes' => $attributes, ]; $match = (object)['route' => $route]; $routeContext = Mockery::mock(RouteContext::class); $routeContext->match = $match; $routeContext->path = $path; $routeContext->shouldReceive('isSuccess')->andReturn(true); return $routeContext; }