349 lines
13 KiB
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;
|
|
}
|
|
}
|