handler = new TenantAccessDeniedHandler(); } #[Test] public function itConvertsAccessDeniedExceptionForTenantAwareResourceTo404(): void { // Create a TenantAware resource $resource = $this->createMock(TenantAwareInterface::class); $resource->method('getTenantId') ->willReturn(TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890')); // Create AccessDeniedException with TenantAware subject $exception = new AccessDeniedException('Access Denied', null); // Note: Symfony's AccessDeniedException doesn't have a public setSubject method, // but it stores the subject internally. For this test, we'll use reflection. $reflection = new ReflectionClass($exception); $property = $reflection->getProperty('subject'); $property->setAccessible(true); $property->setValue($exception, $resource); $event = $this->createExceptionEvent($exception); $this->handler->onKernelException($event); // CRITICAL: Must return 404, not 403 self::assertTrue($event->hasResponse()); self::assertSame(Response::HTTP_NOT_FOUND, $event->getResponse()?->getStatusCode()); // Verify error message is generic (no information leakage) $content = json_decode((string) $event->getResponse()->getContent(), true); self::assertSame('Resource not found', $content['message']); self::assertSame(Response::HTTP_NOT_FOUND, $content['status']); self::assertSame('https://classeo.fr/errors/resource-not-found', $content['type']); // Must NOT reveal tenant information self::assertArrayNotHasKey('tenant', $content); self::assertArrayNotHasKey('resource', $content); } #[Test] public function itDoesNotModifyAccessDeniedExceptionForNonTenantAwareResources(): void { // Regular access denied (not tenant-related) $exception = new AccessDeniedException('Access Denied'); $event = $this->createExceptionEvent($exception); $this->handler->onKernelException($event); // Should NOT set a response - let other handlers deal with it self::assertFalse($event->hasResponse()); } #[Test] public function itIgnoresOtherExceptions(): void { // Non-AccessDeniedException $exception = new RuntimeException('Some error'); $event = $this->createExceptionEvent($exception); $this->handler->onKernelException($event); // Should NOT set a response self::assertFalse($event->hasResponse()); } #[Test] public function itReturns404NotRevealingResourceExistence(): void { // This test specifically validates the security requirement: // 403 reveals resource exists, 404 hides it $resource = $this->createMock(TenantAwareInterface::class); $resource->method('getTenantId') ->willReturn(TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901')); $exception = new AccessDeniedException('You cannot access this resource', null); $reflection = new ReflectionClass($exception); $property = $reflection->getProperty('subject'); $property->setAccessible(true); $property->setValue($exception, $resource); $event = $this->createExceptionEvent($exception); $this->handler->onKernelException($event); $response = $event->getResponse(); self::assertNotNull($response); // MUST be 404 (not 403) to prevent enumeration self::assertSame(404, $response->getStatusCode()); self::assertNotSame(403, $response->getStatusCode(), 'SECURITY: Must be 404, not 403'); } private function createExceptionEvent(Throwable $exception): ExceptionEvent { $kernel = $this->createMock(HttpKernelInterface::class); $request = Request::create('/api/resource/123'); return new ExceptionEvent( $kernel, $request, HttpKernelInterface::MAIN_REQUEST, $exception ); } }