Files
ephub/tests/Controller/Admin/AccessCodeAdminControllerTest.php

349 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin;
use App\Entity\AccessCode;
use App\Entity\User;
use App\Enum\CodeVisibility;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* Maintaining the access codes: the table of everything there is, and the three writes.
*
* The quick look at /access-codes is the other half and is tested in AccessCodeControllerTest --
* what it shows is one card per door, what this shows is every row including the ones that page
* deliberately leaves out.
*/
class AccessCodeAdminControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $em;
protected function setUp(): void
{
$this->client = self::createClient(server: ['HTTPS' => true]);
$this->client->disableReboot();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->beginTransaction();
}
protected function tearDown(): void
{
$this->em->getConnection()->rollBack();
parent::tearDown();
}
public function testAnEmployeeCannotReachIt(): void
{
$code = $this->code('Aufzug', '4711');
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/admin/access-codes');
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
$this->client->request('GET', '/admin/access-codes/new', server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
$this->client->request('GET', \sprintf('/admin/access-codes/%d/edit', $code->getId()), server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
$this->client->request('POST', \sprintf('/admin/access-codes/%d/delete', $code->getId()), ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
$this->em->clear();
self::assertNotNull($this->em->find(AccessCode::class, $code->getId()));
$this->client->request('POST', '/admin/access-codes/notify', ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
self::assertEmailCount(0);
}
/**
* Every row, newest per door first, each marked with what it is today: the one in force
* carries no badge, its successor is „noch nicht gültig" and its predecessor „ersetzt".
*/
public function testTheTableMarksUpcomingAndSupersededCodes(): void
{
$this->code('Aufzug', 'OLD', validFrom: '-2 months');
$this->code('Aufzug', 'NOW', validFrom: '-1 day');
$this->code('Aufzug', 'NEXT', validFrom: '+1 month');
$this->client->loginUser($this->admin());
$rows = $this->client->request('GET', '/admin/access-codes')->filter('[data-access-code]');
self::assertResponseIsSuccessful();
self::assertCount(3, $rows);
self::assertStringContainsString('NEXT', $rows->eq(0)->text());
self::assertStringContainsString('noch nicht gültig', $rows->eq(0)->text());
self::assertStringContainsString('NOW', $rows->eq(1)->text());
self::assertStringNotContainsString('ersetzt', $rows->eq(1)->text());
self::assertStringNotContainsString('noch nicht gültig', $rows->eq(1)->text());
self::assertStringContainsString('OLD', $rows->eq(2)->text());
self::assertStringContainsString('ersetzt', $rows->eq(2)->text());
}
/**
* A door whose code only starts next week has nothing in force -- and nothing may be marked
* as replaced, because nothing has replaced anything yet.
*/
public function testACodeThatHasNotStartedYetSupersedesNothing(): void
{
$this->code('Schranke', 'SOON', validFrom: '+1 week');
$this->client->loginUser($this->admin());
$rows = $this->client->request('GET', '/admin/access-codes')->filter('[data-access-code]');
self::assertCount(1, $rows);
self::assertStringContainsString('noch nicht gültig', $rows->text());
self::assertStringNotContainsString('ersetzt', $rows->text());
}
public function testTheQuickLookOffersTheTableToAnAdmin(): void
{
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/access-codes');
self::assertCount(1, $crawler->filter('a[href="/access-codes"]'));
}
public function testCreatingACode(): void
{
$this->client->loginUser($this->admin());
$response = $this->submit('/admin/access-codes/new', 'access_code_form', [
'label' => 'Aufzug',
'description' => 'Kabine im Erdgeschoss',
'code' => '4711',
'validFrom' => '2026-10-01',
'visibility' => 'everyone',
]);
self::assertSame('/admin/access-codes', $response->headers->get('HX-Redirect'));
$created = $this->em->getRepository(AccessCode::class)->findOneBy(['label' => 'Aufzug']);
self::assertNotNull($created);
self::assertSame('4711', $created->getCode());
self::assertSame('Kabine im Erdgeschoss', $created->getDescription());
self::assertSame('2026-10-01', $created->getValidFrom()->format('Y-m-d'));
self::assertSame(CodeVisibility::EVERYONE, $created->getVisibility());
}
/**
* The date is prefilled with today, because a code being entered usually already works.
*/
public function testTheNewFormStartsToday(): void
{
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/access-codes/new', server: ['HTTP_HX-Request' => 'true']);
self::assertSame(date('Y-m-d'), $crawler->filter('#access_code_form_validFrom')->attr('value'));
}
public function testAMissingLabelIsRejected(): void
{
$this->client->loginUser($this->admin());
$response = $this->submit('/admin/access-codes/new', 'access_code_form', [
'label' => '',
'code' => '4711',
'validFrom' => '2026-10-01',
'visibility' => 'everyone',
]);
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
self::assertCount(0, $this->em->getRepository(AccessCode::class)->findAll());
}
public function testEditingACodeIncludingItsVisibility(): void
{
$code = $this->code('Serverraum', '4711');
$this->client->loginUser($this->admin());
$response = $this->submit(\sprintf('/admin/access-codes/%d/edit', $code->getId()), 'access_code_form', [
'label' => 'Serverraum',
'description' => '',
'code' => '9999',
'validFrom' => $code->getValidFrom()->format('Y-m-d'),
'visibility' => 'admin',
]);
self::assertSame('/admin/access-codes', $response->headers->get('HX-Redirect'));
$code = $this->reload($code);
self::assertSame('9999', $code->getCode());
self::assertSame(CodeVisibility::ADMIN, $code->getVisibility());
self::assertNull($code->getDescription());
}
/**
* Two codes for the same door is the ordinary way to rotate one, so nothing may refuse it.
*/
public function testASecondCodeForTheSameDoorIsAccepted(): void
{
$this->code('Aufzug', '4711');
$this->client->loginUser($this->admin());
$this->submit('/admin/access-codes/new', 'access_code_form', [
'label' => 'Aufzug',
'code' => '9999',
'validFrom' => '2026-12-01',
'visibility' => 'everyone',
]);
self::assertCount(2, $this->em->getRepository(AccessCode::class)->findBy(['label' => 'Aufzug']));
}
public function testDeletingACode(): void
{
$code = $this->code('Aufzug', '4711');
$id = $code->getId();
$this->client->loginUser($this->admin());
$uri = \sprintf('/admin/access-codes/%d/delete', $id);
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
self::assertSame('/admin/access-codes', $this->client->getResponse()->headers->get('HX-Redirect'));
$this->em->clear();
self::assertNull($this->em->find(AccessCode::class, $id));
}
public function testDeletingACodeNeedsAValidToken(): void
{
$code = $this->code('Aufzug', '4711');
$this->client->loginUser($this->admin());
$this->client->request('POST', \sprintf('/admin/access-codes/%d/delete', $code->getId()), ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
$this->em->clear();
self::assertNotNull($this->em->find(AccessCode::class, $code->getId()));
}
/**
* Everybody who can sign in gets a mail of their own, and none of them contains a code -- not
* even one only the recipient's own role could read.
*/
public function testNotifyingTheStaffMailsEveryActiveUserWithoutTheCodes(): void
{
$this->code('Aufzug', '4711');
$this->code('Tresor', '9876');
$admin = $this->admin();
$this->user('[email protected]');
$locked = $this->user('[email protected]');
$locked->disable('ausgeschieden');
$this->em->flush();
$expected = $this->em->getRepository(User::class)->count(['disabledAt' => null]);
$this->client->loginUser($admin);
$uri = '/admin/access-codes/notify';
$modal = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
self::assertStringContainsString(\sprintf('%d Mitarbeitende', $expected), $modal->text());
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
self::assertSame('/admin/access-codes', $this->client->getResponse()->headers->get('HX-Redirect'));
self::assertEmailCount($expected);
$recipients = [];
foreach (self::getMailerMessages() as $email) {
self::assertCount(1, $email->getTo());
$recipients[] = $email->getTo()[0]->getAddress();
foreach ([$email->getTextBody(), $email->getHtmlBody()] as $body) {
self::assertStringContainsString('https://localhost/access-codes', $body);
self::assertStringNotContainsString('4711', $body);
self::assertStringNotContainsString('9876', $body);
self::assertStringNotContainsString('Aufzug', $body);
}
}
self::assertContains('[email protected]', $recipients);
self::assertContains('[email protected]', $recipients);
self::assertNotContains('[email protected]', $recipients);
}
public function testNotifyingTheStaffNeedsAValidToken(): void
{
$this->client->loginUser($this->admin());
$this->client->request('POST', '/admin/access-codes/notify', ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
self::assertEmailCount(0);
}
/**
* Opens the modal, then posts the given fields with its CSRF token -- the round trip a
* browser makes.
*
* @param array<string, string> $fields
*/
private function submit(string $uri, string $formName, array $fields): Response
{
$crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
self::assertResponseIsSuccessful();
$fields['_token'] = $crawler->filter(\sprintf('#%s__token', $formName))->attr('value');
$this->client->request('POST', $uri, [$formName => $fields], server: ['HTTP_HX-Request' => 'true']);
return $this->client->getResponse();
}
private function confirmationToken(string $uri): string
{
$crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
self::assertResponseIsSuccessful();
$vals = $crawler->filter('#htmx-confirmation-modal [hx-post]')->attr('hx-vals');
return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
}
/**
* Each request resets the entity manager, so what a test holds is detached afterwards.
*/
private function reload(AccessCode $code): AccessCode
{
$fresh = $this->em->find(AccessCode::class, $code->getId());
self::assertNotNull($fresh);
return $fresh;
}
private function code(string $label, string $value, string $validFrom = 'today'): AccessCode
{
$code = new AccessCode($label, $value, new \DateTimeImmutable($validFrom));
$this->em->persist($code);
$this->em->flush();
return $code;
}
private function admin(): User
{
return $this->user('[email protected]', ['ROLE_ADMIN']);
}
/**
* @param list<string> $roles
*/
private function user(string $email, array $roles = ['ROLE_EMPLOYEE']): User
{
$existing = $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
if (null !== $existing) {
return $existing;
}
$user = new User($email, 'Test');
$user->setRoles($roles);
$this->em->persist($user);
$this->em->flush();
return $user;
}
}