diff --git a/src/Controller/AuditLogController.php b/src/Controller/AuditLogController.php new file mode 100644 index 000000000..fefb7290e --- /dev/null +++ b/src/Controller/AuditLogController.php @@ -0,0 +1,60 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Controller; + +use App\Audit\AuditRecordType; +use App\Entity\AuditRecordRepository; +use App\QueryFilter\AuditLog\AuditRecordTypeFilter; +use App\QueryFilter\QueryFilterInterface; +use Pagerfanta\Doctrine\ORM\QueryAdapter; +use Pagerfanta\Pagerfanta; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; + +class AuditLogController extends Controller +{ + #[IsGranted('ROLE_USER')] + #[Route(path: '/audit-log', name: 'view_audit_logs')] + public function viewAuditLogs(Request $request, AuditRecordRepository $auditRecordRepository): Response + { + /** @var QueryFilterInterface[] $filters */ + $filters = [ + AuditRecordTypeFilter::fromQuery($request->query), + ]; + + $qb = $auditRecordRepository->createQueryBuilder('a') + ->orderBy('a.id', 'DESC'); + + foreach ($filters as $filter) { + $filter->filter($qb); + } + + $auditLogs = new Pagerfanta(new QueryAdapter($qb, true)); + $auditLogs->setNormalizeOutOfRangePages(true); + $auditLogs->setMaxPerPage(20); + $auditLogs->setCurrentPage(max(1, $request->query->getInt('page', 1))); + + $selectedFilters = []; + foreach ($filters as $filter) { + $selectedFilters[$filter->getKey()] = $filter->getSelectedValue(); + } + + return $this->render('audit_log/view_audit_logs.html.twig', [ + 'auditLogs' => $auditLogs, + 'allTypes' => AuditRecordType::cases(), + 'selectedFilters' => $selectedFilters, + ]); + } +} diff --git a/src/QueryFilter/AuditLog/AuditRecordTypeFilter.php b/src/QueryFilter/AuditLog/AuditRecordTypeFilter.php new file mode 100644 index 000000000..81254be71 --- /dev/null +++ b/src/QueryFilter/AuditLog/AuditRecordTypeFilter.php @@ -0,0 +1,74 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\QueryFilter\AuditLog; + +use App\Audit\AuditRecordType; +use App\QueryFilter\QueryFilterInterface; +use Doctrine\ORM\QueryBuilder; +use Symfony\Component\HttpFoundation\InputBag; + +class AuditRecordTypeFilter implements QueryFilterInterface +{ + /** + * @param string[] $types + */ + final private function __construct( + private readonly string $key, + private readonly array $types = [], + ) {} + + public function filter(QueryBuilder $qb): QueryBuilder + { + if (count($this->types) === 0) { + return $qb; + } + + $qb->andWhere('a.type IN (:types)') + ->setParameter('types', $this->types); + + return $qb; + } + + public function getSelectedValue(): mixed + { + return $this->types; + } + + public function getKey(): string + { + return $this->key; + } + + /** + * @param InputBag $bag + */ + public static function fromQuery(InputBag $bag, string $key = 'type'): static + { + $values = $bag->all($key); + + if (empty($values)) { + return new static($key); + } + + $types = array_filter($values, fn (string $inputValue) => self::isValid($inputValue)); + + return new static($key, array_values($types)); + } + + private static function isValid(string $value): bool + { + $enum = AuditRecordType::tryFrom($value); + + return $enum !== null; + } +} diff --git a/src/QueryFilter/QueryFilterInterface.php b/src/QueryFilter/QueryFilterInterface.php new file mode 100644 index 000000000..7a5969915 --- /dev/null +++ b/src/QueryFilter/QueryFilterInterface.php @@ -0,0 +1,22 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\QueryFilter; + +use Doctrine\ORM\QueryBuilder; + +interface QueryFilterInterface +{ + public function filter(QueryBuilder $qb): QueryBuilder; + public function getKey(): string; + public function getSelectedValue(): mixed; +} diff --git a/templates/audit_log/view_audit_logs.html.twig b/templates/audit_log/view_audit_logs.html.twig new file mode 100644 index 000000000..27b5b5ff2 --- /dev/null +++ b/templates/audit_log/view_audit_logs.html.twig @@ -0,0 +1,59 @@ +{% extends "layout.html.twig" %} + +{% block title %} + Packagist - Audit Logs +{% endblock %} + +{% block content %} +

Audit Logs

+ +
+
+
+
+ + +
+
+
+ + +
+
+
+ + {% if auditLogs|length %} + + + + + + + + + + {% for log in auditLogs %} + + + + + + {% endfor %} + +
Date & TimeTypePackage
{{ log.datetime|date('Y-m-d H:i:s') }} UTC{{ ('audit_log.type.'~log.type.value)|trans }}{{ log.attributes['name'] ?? '-' }}
+ + {% if auditLogs.haveToPaginate() %} + {{ pagerfanta(auditLogs, 'twitter_bootstrap', {'proximity': 2}) }} + {% endif %} + {% else %} +
+

No audit logs found.

+
+ {% endif %} +{% endblock %} diff --git a/tests/Controller/AuditLogControllerTest.php b/tests/Controller/AuditLogControllerTest.php new file mode 100644 index 000000000..3b2947f63 --- /dev/null +++ b/tests/Controller/AuditLogControllerTest.php @@ -0,0 +1,55 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests\Controller; + +use App\Audit\AuditRecordType; +use App\Entity\AuditRecord; +use App\Tests\IntegrationTestCase; +use PHPUnit\Framework\Attributes\DataProvider; + +class AuditLogControllerTest extends IntegrationTestCase +{ + #[DataProvider('filterProvider')] + public function testViewAuditLogs(array $filters, array $expected): void + { + $user = self::createUser('testuser', 'test@example.com', roles: ['ROLE_USER']); + $package = self::createPackage('vendor1/package1', 'https://github.com/vendor1/package1', maintainers: [$user]); + + $this->store($user, $package); + + $auditRecord1 = AuditRecord::canonicalUrlChange($package, $user, 'https://github.com/vendor1/package1-new'); + $auditRecord = AuditRecord::packageDeleted($package, $user); + + $this->store($auditRecord1, $auditRecord); + + $this->client->loginUser($user); + $crawler = $this->client->request('GET', '/audit-log?' . http_build_query($filters)); + static::assertResponseIsSuccessful(); + + $rows = $crawler->filter('[data-test=audit-log-type]'); + static::assertSame($expected, $rows->each(fn ($element) => trim($element->text()))); + } + + public static function filterProvider(): iterable + { + yield [ + [], + ['Package deleted', 'Canonical URL changed', 'Package created'], + ]; + + yield [ + ['type' => [AuditRecordType::CanonicalUrlChanged->value, AuditRecordType::PackageDeleted->value]], + ['Package deleted', 'Canonical URL changed'], + ]; + } +} diff --git a/tests/IntegrationTestCase.php b/tests/IntegrationTestCase.php index cac650769..05fd5eb1d 100644 --- a/tests/IntegrationTestCase.php +++ b/tests/IntegrationTestCase.php @@ -111,6 +111,7 @@ protected static function createUser(string $username = 'test', string $email = $user->setApiToken($apiToken); $user->setSafeApiToken($safeApiToken); $user->setGithubId($githubId); + $user->setRoles($roles); return $user; } diff --git a/tests/QueryFilter/AuditLog/AuditRecordTypeFilterTest.php b/tests/QueryFilter/AuditLog/AuditRecordTypeFilterTest.php new file mode 100644 index 000000000..bcd95287d --- /dev/null +++ b/tests/QueryFilter/AuditLog/AuditRecordTypeFilterTest.php @@ -0,0 +1,90 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests\QueryFilter\AuditLog; + +use App\Audit\AuditRecordType; +use App\QueryFilter\AuditLog\AuditRecordTypeFilter; +use Doctrine\ORM\QueryBuilder; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\InputBag; + +class AuditRecordTypeFilterTest extends TestCase +{ + private EntityManagerInterface $entityManager; + + protected function setUp(): void + { + $this->entityManager = $this->createMock(EntityManagerInterface::class); + } + + public function testFromQueryWithEmptyInput(): void + { + $bag = new InputBag([]); + $filter = AuditRecordTypeFilter::fromQuery($bag); + + $this->assertSame('type', $filter->getKey()); + $this->assertSame([], $filter->getSelectedValue()); + } + + public function testFromQueryWithMultipleValidAndInvalidTypes(): void + { + $types = [ + AuditRecordType::PackageCreated->value, + 'invalid_type', + AuditRecordType::VersionDeleted->value, + ]; + + $bag = new InputBag(['type' => $types]); + $filter = AuditRecordTypeFilter::fromQuery($bag); + + $this->assertSame( + [AuditRecordType::PackageCreated->value, AuditRecordType::VersionDeleted->value], + $filter->getSelectedValue() + ); + } + + public function testFilterWithEmptyTypes(): void + { + $bag = new InputBag([]); + $filter = AuditRecordTypeFilter::fromQuery($bag); + + $qb = new QueryBuilder($this->entityManager); + $result = $filter->filter($qb); + + $this->assertSame($qb, $result); + $this->assertNull($qb->getDQLPart('where')); + } + + public function testFilterWithTypes(): void + { + $types = [ + AuditRecordType::PackageCreated->value, + AuditRecordType::VersionReferenceChanged->value, + ]; + + $bag = new InputBag(['type' => $types]); + $filter = AuditRecordTypeFilter::fromQuery($bag); + + $qb = new QueryBuilder($this->entityManager); + $result = $filter->filter($qb); + + $this->assertSame($qb, $result); + $this->assertNotNull($qb->getDQLPart('where')); + $this->assertEqualsCanonicalizing( + $types, + $qb->getParameter('types')->getValue() + ); + } +} diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 5b77d9723..d01189971 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -168,3 +168,27 @@ freezing_reasons: spam: This package was marked as spam and has been frozen as a result. remote_id: This package's canonical repository id has changed and the package has been frozen as a result. Email us for help if needed. gone: This package's canonical repository appears to be gone and the package has been frozen as a result. Email us for help if needed. + +audit_log: + type: + canonical_url_changed: Canonical URL changed + email_changed: Email changed + github_disconnected_from_user: GitHub disconnected from user + github_linked_with_user: GitHub linked to user + maintainer_added: Maintainer added + maintainer_removed: Maintainer removed + package_abandoned: Package abandoned + package_created: Package created + package_deleted: Package deleted + package_transferred: Package transferred + package_unabandoned: Package unabandoned + password_changed: Password changed + password_reset: Password reset + password_reset_requested: Password reset requested + two_fa_activated: Two-factor authentication activated + two_fa_deactivated: Two-factor authentication deactivated + user_created: User created + user_deleted: User deleted + username_changed: Username changed + version_deleted: Version deleted + version_reference_changed: Version reference changed