Skip to content

Commit 7301c81

Browse files
Add first implemention for filters to the audit log
1 parent ca527e7 commit 7301c81

File tree

7 files changed

+270
-17
lines changed

7 files changed

+270
-17
lines changed

src/Controller/AuditLogController.php

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212

1313
namespace App\Controller;
1414

15+
use App\Audit\AuditRecordType;
1516
use App\Entity\AuditRecordRepository;
17+
use App\QueryFilter\AuditLog\AuditRecordTypeFilter;
18+
use App\QueryFilter\QueryFilterInterface;
1619
use Pagerfanta\Doctrine\ORM\QueryAdapter;
1720
use Pagerfanta\Pagerfanta;
1821
use Symfony\Component\HttpFoundation\Request;
@@ -24,18 +27,34 @@ class AuditLogController extends Controller
2427
{
2528
#[IsGranted('ROLE_USER')]
2629
#[Route(path: '/audit-log', name: 'view_audit_logs')]
27-
public function viewAuditLogs(Request $req, AuditRecordRepository $auditRecordRepository): Response
30+
public function viewAuditLogs(Request $request, AuditRecordRepository $auditRecordRepository): Response
2831
{
29-
$query = $auditRecordRepository->createQueryBuilder('a')
32+
/** @var QueryFilterInterface[] $filters */
33+
$filters = [
34+
AuditRecordTypeFilter::fromQuery($request->query),
35+
];
36+
37+
$qb = $auditRecordRepository->createQueryBuilder('a')
3038
->orderBy('a.id', 'DESC');
3139

32-
$auditLogs = new Pagerfanta(new QueryAdapter($query, false));
40+
foreach ($filters as $filter) {
41+
$filter->filter($qb);
42+
}
43+
44+
$auditLogs = new Pagerfanta(new QueryAdapter($qb, true));
3345
$auditLogs->setNormalizeOutOfRangePages(true);
3446
$auditLogs->setMaxPerPage(20);
35-
$auditLogs->setCurrentPage(max(1, $req->query->getInt('page', 1)));
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+
}
3653

3754
return $this->render('audit_log/view_audit_logs.html.twig', [
3855
'auditLogs' => $auditLogs,
56+
'allTypes' => AuditRecordType::cases(),
57+
'selectedFilters' => $selectedFilters,
3958
]);
4059
}
4160
}
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+
}

templates/audit_log/view_audit_logs.html.twig

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,42 @@
77
{% block content %}
88
<h2 class="title">Audit Logs</h2>
99

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+
1031
{% if auditLogs|length %}
11-
<table class="table table-striped">
32+
<table class="table">
1233
<thead>
1334
<tr>
1435
<th>Date & Time</th>
1536
<th>Type</th>
16-
<th>Vendor</th>
37+
<th>Package</th>
1738
</tr>
1839
</thead>
1940
<tbody>
2041
{% for log in auditLogs %}
2142
<tr>
2243
<td>{{ log.datetime|date('Y-m-d H:i:s') }} UTC</td>
2344
<td data-test="audit-log-type">{{ ('audit_log.type.'~log.type.value)|trans }}</td>
24-
<td>{{ log.vendor ?? '-' }}</td>
45+
<td>{{ log.attributes['name'] ?? '-' }}</td>
2546
</tr>
2647
{% endfor %}
2748
</tbody>

tests/Controller/AuditLogControllerTest.php

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@
1212

1313
namespace App\Tests\Controller;
1414

15+
use App\Audit\AuditRecordType;
1516
use App\Entity\AuditRecord;
1617
use App\Tests\IntegrationTestCase;
18+
use PHPUnit\Framework\Attributes\DataProvider;
1719

1820
class AuditLogControllerTest extends IntegrationTestCase
1921
{
20-
public function testViewAuditLogs(): void
22+
#[DataProvider('filterProvider')]
23+
public function testViewAuditLogs(array $filters, array $expected): void
2124
{
22-
$user = self::createUser('testuser', '[email protected]', roles: ['ROLE_ADMIN']);
25+
$user = self::createUser('testuser', '[email protected]', roles: ['ROLE_USER']);
2326
$package = self::createPackage('vendor1/package1', 'https://github.com/vendor1/package1', maintainers: [$user]);
2427

2528
$this->store($user, $package);
@@ -30,15 +33,23 @@ public function testViewAuditLogs(): void
3033
$this->store($auditRecord1, $auditRecord);
3134

3235
$this->client->loginUser($user);
33-
$crawler = $this->client->request('GET', '/audit-log');
36+
$crawler = $this->client->request('GET', '/audit-log?' . http_build_query($filters));
3437
static::assertResponseIsSuccessful();
3538

3639
$rows = $crawler->filter('[data-test=audit-log-type]');
37-
static::assertGreaterThanOrEqual(3, $rows->count(), 'Should have at least 3 audit log entries');
38-
static::assertSame([
39-
'Package deleted',
40-
'Canonical URL changed',
41-
'Package created',
42-
], $rows->each(fn ($element) => trim($element->text())));
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::CanonicalUrlChange->value, AuditRecordType::PackageDeleted->value]],
52+
['Package deleted', 'Canonical URL changed'],
53+
];
4354
}
4455
}
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::VersionReferenceChange->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+
}

translations/messages.en.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,24 @@ freezing_reasons:
171171

172172
audit_log:
173173
type:
174+
add_maintainer: Maintainer added
175+
canonical_url_change: Canonical URL changed
176+
email_change: Email changed
177+
github_disconnected_from_user: GitHub disconnected from user
178+
github_linked_with_user: GitHub linked to user
179+
package_abandoned: Package abandoned
174180
package_created: Package created
175181
package_deleted: Package deleted
182+
package_unabandoned: Package unabandoned
183+
password_change: Password changed
184+
password_reset: Password reset
185+
password_reset_request: Password reset requested
186+
remove_maintainer: Maintainer removed
187+
transfer_package: Package transferred
188+
two_fa_activated: Two-factor authentication activated
189+
two_fa_deactivated: Two-factor authentication deactivated
190+
user_created: User created
191+
user_deleted: User deleted
192+
username_change: Username changed
176193
version_deleted: Version deleted
177194
version_reference_change: Version reference changed
178-
canonical_url_change: Canonical URL changed

0 commit comments

Comments
 (0)