Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/Controller/AuditLogController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php declare(strict_types=1);

/*
* This file is part of Packagist.
*
* (c) Jordi Boggiano <[email protected]>
* Nils Adermann <[email protected]>
*
* 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,
]);
}
}
74 changes: 74 additions & 0 deletions src/QueryFilter/AuditLog/AuditRecordTypeFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php declare(strict_types=1);

/*
* This file is part of Packagist.
*
* (c) Jordi Boggiano <[email protected]>
* Nils Adermann <[email protected]>
*
* 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<string> $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;
}
}
22 changes: 22 additions & 0 deletions src/QueryFilter/QueryFilterInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php declare(strict_types=1);

/*
* This file is part of Packagist.
*
* (c) Jordi Boggiano <[email protected]>
* Nils Adermann <[email protected]>
*
* 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;
}
59 changes: 59 additions & 0 deletions templates/audit_log/view_audit_logs.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{% extends "layout.html.twig" %}

{% block title %}
Packagist - Audit Logs
{% endblock %}

{% block content %}
<h2 class="title">Audit Logs</h2>

<form method="get" action="{{ path('view_audit_logs') }}" name="filters">
<div class="row">
<div class="col-md-4">
<div class="form-group">
<label for="type-filter">Type:</label>
<select name="type[]" id="type-filter" multiple>
{% for type in allTypes %}
<option value="{{ type.value }}" {% if type.value in selectedFilters.type %}selected="selected"{% endif %}>
{{ ('audit_log.type.'~type.value)|trans }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-4">
<input type="submit" value="Filter">
<input type="reset" value="Reset">
</div>
</div>
</form>

{% if auditLogs|length %}
<table class="table">
<thead>
<tr>
<th>Date & Time</th>
<th>Type</th>
<th>Package</th>
</tr>
</thead>
<tbody>
{% for log in auditLogs %}
<tr>
<td>{{ log.datetime|date('Y-m-d H:i:s') }} UTC</td>
<td data-test="audit-log-type">{{ ('audit_log.type.'~log.type.value)|trans }}</td>
<td>{{ log.attributes['name'] ?? '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>

{% if auditLogs.haveToPaginate() %}
{{ pagerfanta(auditLogs, 'twitter_bootstrap', {'proximity': 2}) }}
{% endif %}
{% else %}
<div class="alert alert-info">
<p>No audit logs found.</p>
</div>
{% endif %}
{% endblock %}
55 changes: 55 additions & 0 deletions tests/Controller/AuditLogControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php declare(strict_types=1);

/*
* This file is part of Packagist.
*
* (c) Jordi Boggiano <[email protected]>
* Nils Adermann <[email protected]>
*
* 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', '[email protected]', 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'],
];
}
}
1 change: 1 addition & 0 deletions tests/IntegrationTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
90 changes: 90 additions & 0 deletions tests/QueryFilter/AuditLog/AuditRecordTypeFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php declare(strict_types=1);

/*
* This file is part of Packagist.
*
* (c) Jordi Boggiano <[email protected]>
* Nils Adermann <[email protected]>
*
* 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()
);
}
}
Loading