Skip to content

Commit c1a1d4f

Browse files
First implementation of audit log page (#1599)
* Add `/audit-log` route * Use a proper translation string for audit record types
1 parent 6a93b31 commit c1a1d4f

File tree

8 files changed

+385
-0
lines changed

8 files changed

+385
-0
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <[email protected]>
7+
* Nils Adermann <[email protected]>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\Controller;
14+
15+
use App\Audit\AuditRecordType;
16+
use App\Entity\AuditRecordRepository;
17+
use App\QueryFilter\AuditLog\AuditRecordTypeFilter;
18+
use App\QueryFilter\QueryFilterInterface;
19+
use Pagerfanta\Doctrine\ORM\QueryAdapter;
20+
use Pagerfanta\Pagerfanta;
21+
use Symfony\Component\HttpFoundation\Request;
22+
use Symfony\Component\HttpFoundation\Response;
23+
use Symfony\Component\Routing\Attribute\Route;
24+
use Symfony\Component\Security\Http\Attribute\IsGranted;
25+
26+
class AuditLogController extends Controller
27+
{
28+
#[IsGranted('ROLE_USER')]
29+
#[Route(path: '/audit-log', name: 'view_audit_logs')]
30+
public function viewAuditLogs(Request $request, AuditRecordRepository $auditRecordRepository): Response
31+
{
32+
/** @var QueryFilterInterface[] $filters */
33+
$filters = [
34+
AuditRecordTypeFilter::fromQuery($request->query),
35+
];
36+
37+
$qb = $auditRecordRepository->createQueryBuilder('a')
38+
->orderBy('a.id', 'DESC');
39+
40+
foreach ($filters as $filter) {
41+
$filter->filter($qb);
42+
}
43+
44+
$auditLogs = new Pagerfanta(new QueryAdapter($qb, true));
45+
$auditLogs->setNormalizeOutOfRangePages(true);
46+
$auditLogs->setMaxPerPage(20);
47+
$auditLogs->setCurrentPage(max(1, $request->query->getInt('page', 1)));
48+
49+
$selectedFilters = [];
50+
foreach ($filters as $filter) {
51+
$selectedFilters[$filter->getKey()] = $filter->getSelectedValue();
52+
}
53+
54+
return $this->render('audit_log/view_audit_logs.html.twig', [
55+
'auditLogs' => $auditLogs,
56+
'allTypes' => AuditRecordType::cases(),
57+
'selectedFilters' => $selectedFilters,
58+
]);
59+
}
60+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <[email protected]>
7+
* Nils Adermann <[email protected]>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\QueryFilter\AuditLog;
14+
15+
use App\Audit\AuditRecordType;
16+
use App\QueryFilter\QueryFilterInterface;
17+
use Doctrine\ORM\QueryBuilder;
18+
use Symfony\Component\HttpFoundation\InputBag;
19+
20+
class AuditRecordTypeFilter implements QueryFilterInterface
21+
{
22+
/**
23+
* @param string[] $types
24+
*/
25+
final private function __construct(
26+
private readonly string $key,
27+
private readonly array $types = [],
28+
) {}
29+
30+
public function filter(QueryBuilder $qb): QueryBuilder
31+
{
32+
if (count($this->types) === 0) {
33+
return $qb;
34+
}
35+
36+
$qb->andWhere('a.type IN (:types)')
37+
->setParameter('types', $this->types);
38+
39+
return $qb;
40+
}
41+
42+
public function getSelectedValue(): mixed
43+
{
44+
return $this->types;
45+
}
46+
47+
public function getKey(): string
48+
{
49+
return $this->key;
50+
}
51+
52+
/**
53+
* @param InputBag<string> $bag
54+
*/
55+
public static function fromQuery(InputBag $bag, string $key = 'type'): static
56+
{
57+
$values = $bag->all($key);
58+
59+
if (empty($values)) {
60+
return new static($key);
61+
}
62+
63+
$types = array_filter($values, fn (string $inputValue) => self::isValid($inputValue));
64+
65+
return new static($key, array_values($types));
66+
}
67+
68+
private static function isValid(string $value): bool
69+
{
70+
$enum = AuditRecordType::tryFrom($value);
71+
72+
return $enum !== null;
73+
}
74+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <[email protected]>
7+
* Nils Adermann <[email protected]>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\QueryFilter;
14+
15+
use Doctrine\ORM\QueryBuilder;
16+
17+
interface QueryFilterInterface
18+
{
19+
public function filter(QueryBuilder $qb): QueryBuilder;
20+
public function getKey(): string;
21+
public function getSelectedValue(): mixed;
22+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{% extends "layout.html.twig" %}
2+
3+
{% block title %}
4+
Packagist - Audit Logs
5+
{% endblock %}
6+
7+
{% block content %}
8+
<h2 class="title">Audit Logs</h2>
9+
10+
<form method="get" action="{{ path('view_audit_logs') }}" name="filters">
11+
<div class="row">
12+
<div class="col-md-4">
13+
<div class="form-group">
14+
<label for="type-filter">Type:</label>
15+
<select name="type[]" id="type-filter" multiple>
16+
{% for type in allTypes %}
17+
<option value="{{ type.value }}" {% if type.value in selectedFilters.type %}selected="selected"{% endif %}>
18+
{{ ('audit_log.type.'~type.value)|trans }}
19+
</option>
20+
{% endfor %}
21+
</select>
22+
</div>
23+
</div>
24+
<div class="col-md-4">
25+
<input type="submit" value="Filter">
26+
<input type="reset" value="Reset">
27+
</div>
28+
</div>
29+
</form>
30+
31+
{% if auditLogs|length %}
32+
<table class="table">
33+
<thead>
34+
<tr>
35+
<th>Date & Time</th>
36+
<th>Type</th>
37+
<th>Package</th>
38+
</tr>
39+
</thead>
40+
<tbody>
41+
{% for log in auditLogs %}
42+
<tr>
43+
<td>{{ log.datetime|date('Y-m-d H:i:s') }} UTC</td>
44+
<td data-test="audit-log-type">{{ ('audit_log.type.'~log.type.value)|trans }}</td>
45+
<td>{{ log.attributes['name'] ?? '-' }}</td>
46+
</tr>
47+
{% endfor %}
48+
</tbody>
49+
</table>
50+
51+
{% if auditLogs.haveToPaginate() %}
52+
{{ pagerfanta(auditLogs, 'twitter_bootstrap', {'proximity': 2}) }}
53+
{% endif %}
54+
{% else %}
55+
<div class="alert alert-info">
56+
<p>No audit logs found.</p>
57+
</div>
58+
{% endif %}
59+
{% endblock %}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <[email protected]>
7+
* Nils Adermann <[email protected]>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\Tests\Controller;
14+
15+
use App\Audit\AuditRecordType;
16+
use App\Entity\AuditRecord;
17+
use App\Tests\IntegrationTestCase;
18+
use PHPUnit\Framework\Attributes\DataProvider;
19+
20+
class AuditLogControllerTest extends IntegrationTestCase
21+
{
22+
#[DataProvider('filterProvider')]
23+
public function testViewAuditLogs(array $filters, array $expected): void
24+
{
25+
$user = self::createUser('testuser', '[email protected]', roles: ['ROLE_USER']);
26+
$package = self::createPackage('vendor1/package1', 'https://github.com/vendor1/package1', maintainers: [$user]);
27+
28+
$this->store($user, $package);
29+
30+
$auditRecord1 = AuditRecord::canonicalUrlChange($package, $user, 'https://github.com/vendor1/package1-new');
31+
$auditRecord = AuditRecord::packageDeleted($package, $user);
32+
33+
$this->store($auditRecord1, $auditRecord);
34+
35+
$this->client->loginUser($user);
36+
$crawler = $this->client->request('GET', '/audit-log?' . http_build_query($filters));
37+
static::assertResponseIsSuccessful();
38+
39+
$rows = $crawler->filter('[data-test=audit-log-type]');
40+
static::assertSame($expected, $rows->each(fn ($element) => trim($element->text())));
41+
}
42+
43+
public static function filterProvider(): iterable
44+
{
45+
yield [
46+
[],
47+
['Package deleted', 'Canonical URL changed', 'Package created'],
48+
];
49+
50+
yield [
51+
['type' => [AuditRecordType::CanonicalUrlChanged->value, AuditRecordType::PackageDeleted->value]],
52+
['Package deleted', 'Canonical URL changed'],
53+
];
54+
}
55+
}

tests/IntegrationTestCase.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ protected static function createUser(string $username = 'test', string $email =
111111
$user->setApiToken($apiToken);
112112
$user->setSafeApiToken($safeApiToken);
113113
$user->setGithubId($githubId);
114+
$user->setRoles($roles);
114115

115116
return $user;
116117
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <[email protected]>
7+
* Nils Adermann <[email protected]>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\Tests\QueryFilter\AuditLog;
14+
15+
use App\Audit\AuditRecordType;
16+
use App\QueryFilter\AuditLog\AuditRecordTypeFilter;
17+
use Doctrine\ORM\QueryBuilder;
18+
use Doctrine\ORM\EntityManagerInterface;
19+
use PHPUnit\Framework\Attributes\DataProvider;
20+
use PHPUnit\Framework\TestCase;
21+
use Symfony\Component\HttpFoundation\InputBag;
22+
23+
class AuditRecordTypeFilterTest extends TestCase
24+
{
25+
private EntityManagerInterface $entityManager;
26+
27+
protected function setUp(): void
28+
{
29+
$this->entityManager = $this->createMock(EntityManagerInterface::class);
30+
}
31+
32+
public function testFromQueryWithEmptyInput(): void
33+
{
34+
$bag = new InputBag([]);
35+
$filter = AuditRecordTypeFilter::fromQuery($bag);
36+
37+
$this->assertSame('type', $filter->getKey());
38+
$this->assertSame([], $filter->getSelectedValue());
39+
}
40+
41+
public function testFromQueryWithMultipleValidAndInvalidTypes(): void
42+
{
43+
$types = [
44+
AuditRecordType::PackageCreated->value,
45+
'invalid_type',
46+
AuditRecordType::VersionDeleted->value,
47+
];
48+
49+
$bag = new InputBag(['type' => $types]);
50+
$filter = AuditRecordTypeFilter::fromQuery($bag);
51+
52+
$this->assertSame(
53+
[AuditRecordType::PackageCreated->value, AuditRecordType::VersionDeleted->value],
54+
$filter->getSelectedValue()
55+
);
56+
}
57+
58+
public function testFilterWithEmptyTypes(): void
59+
{
60+
$bag = new InputBag([]);
61+
$filter = AuditRecordTypeFilter::fromQuery($bag);
62+
63+
$qb = new QueryBuilder($this->entityManager);
64+
$result = $filter->filter($qb);
65+
66+
$this->assertSame($qb, $result);
67+
$this->assertNull($qb->getDQLPart('where'));
68+
}
69+
70+
public function testFilterWithTypes(): void
71+
{
72+
$types = [
73+
AuditRecordType::PackageCreated->value,
74+
AuditRecordType::VersionReferenceChanged->value,
75+
];
76+
77+
$bag = new InputBag(['type' => $types]);
78+
$filter = AuditRecordTypeFilter::fromQuery($bag);
79+
80+
$qb = new QueryBuilder($this->entityManager);
81+
$result = $filter->filter($qb);
82+
83+
$this->assertSame($qb, $result);
84+
$this->assertNotNull($qb->getDQLPart('where'));
85+
$this->assertEqualsCanonicalizing(
86+
$types,
87+
$qb->getParameter('types')->getValue()
88+
);
89+
}
90+
}

0 commit comments

Comments
 (0)