seedAuditLog(); $client->request('GET', '/api/audit-logs'); self::assertResponseStatusCodeSame(401); } public function testUserWithoutPermissionIsForbidden(): void { $client = self::createClient(); $this->seedAuditLog(); $this->loginUser($client, 'alice'); $client->request('GET', '/api/audit-logs'); self::assertResponseStatusCodeSame(403); } public function testAdminCanListAuditLogs(): void { $client = self::createClient(); $this->seedAuditLog(); $this->loginUser($client, 'admin'); $client->request('GET', '/api/audit-logs'); self::assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); self::assertArrayHasKey('member', $data); self::assertArrayHasKey('totalItems', $data); self::assertGreaterThanOrEqual(3, $data['totalItems']); } public function testFilterByActionReturnsOnlyMatchingEntries(): void { $client = self::createClient(); $this->seedAuditLog(); $this->loginUser($client, 'admin'); $client->request('GET', '/api/audit-logs?action=update'); self::assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); self::assertNotEmpty($data['member']); foreach ($data['member'] as $entry) { self::assertSame('update', $entry['action']); } } public function testFilterByEntityType(): void { $client = self::createClient(); $this->seedAuditLog(); $this->loginUser($client, 'admin'); $client->request('GET', '/api/audit-logs?entity_type=core.User'); self::assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); self::assertNotEmpty($data['member']); foreach ($data['member'] as $entry) { self::assertSame('core.User', $entry['entityType']); } } public function testInvalidActionFilterIsRejected(): void { $client = self::createClient(); $this->seedAuditLog(); $this->loginUser($client, 'admin'); $client->request('GET', '/api/audit-logs?action=bogus'); self::assertResponseStatusCodeSame(400); } /** * Insert deterministic audit rows directly through the audit connection. * * The table audit_log has no ORM entity (written via raw DBAL), so we * clean and seed it via the dedicated connection. These tests are not * wrapped in a rolled-back transaction, hence the upfront DELETE. */ private function seedAuditLog(): void { /** @var Connection $audit */ $audit = self::getContainer()->get('doctrine.dbal.audit_connection'); $audit->executeStatement('DELETE FROM audit_log'); $rows = [ ['core.User', 'create', '{"username":"seed-create"}'], ['core.User', 'update', '{"firstName":{"old":"a","new":"b"}}'], ['core.Role', 'delete', '{}'], ]; $performedAt = '2026-04-22 10:00:00'; foreach ($rows as $i => [$entityType, $action, $changes]) { $audit->insert('audit_log', [ 'id' => Uuid::v7()->toRfc4122(), 'entity_type' => $entityType, 'entity_id' => (string) ($i + 1), 'action' => $action, 'changes' => $changes, 'performed_by' => 'admin', 'performed_at' => $performedAt, 'ip_address' => '127.0.0.1', 'request_id' => 'req-'.$i, ]); } } private function loginUser(KernelBrowser $client, string $username): void { $em = self::getContainer()->get(EntityManagerInterface::class); $user = $em->getRepository(User::class)->findOneBy(['username' => $username]); self::assertInstanceOf(User::class, $user); $client->loginUser($user); } }