diff --git a/assets/icons/lucide/key-round.svg b/assets/icons/lucide/key-round.svg new file mode 100644 index 0000000..bc3350a --- /dev/null +++ b/assets/icons/lucide/key-round.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/migrations/Version20260925120000.php b/migrations/Version20260925120000.php new file mode 100644 index 0000000..7ad8aec --- /dev/null +++ b/migrations/Version20260925120000.php @@ -0,0 +1,28 @@ +addSql('CREATE TABLE access_code (id INT AUTO_INCREMENT NOT NULL, label VARCHAR(50) NOT NULL, code VARCHAR(32) NOT NULL, valid_from DATE NOT NULL, description VARCHAR(255) DEFAULT NULL, visibility VARCHAR(20) NOT NULL, INDEX idx_access_code_valid_from (valid_from), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE access_code'); + } +} diff --git a/src/Controller/AccessCode/IndexController.php b/src/Controller/AccessCode/IndexController.php new file mode 100644 index 0000000..6dc3f12 --- /dev/null +++ b/src/Controller/AccessCode/IndexController.php @@ -0,0 +1,65 @@ +render('access_code/index.html.twig', [ + 'codes' => $this->current($this->codes->findCurrentReadableBy($this->currentUser()->getRoles(), $today)), + ]); + } + + /** + * One code per door: the newest of those already in force, which the repository puts first. + * + * A door only has more than one here because its code was rotated, and then the older rows + * are history -- readable in the admin table, not on the screen somebody is standing at a + * door with. + * + * @param list $codes ordered by label, newest first within a label + * + * @return list + */ + private function current(array $codes): array + { + $current = []; + + foreach ($codes as $code) { + $current[$code->getLabel()] ??= $code; + } + + return array_values($current); + } +} diff --git a/src/Controller/Admin/AccessCode/CreateController.php b/src/Controller/Admin/AccessCode/CreateController.php new file mode 100644 index 0000000..7182daf --- /dev/null +++ b/src/Controller/Admin/AccessCode/CreateController.php @@ -0,0 +1,55 @@ +createForm(AccessCodeFormType::class, $code, [ + 'action' => $this->generateUrl('app_admin_access_code_new'), + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->em->persist($code); + $this->em->flush(); + + $this->audit->accessCodeCreated($code); + $this->addFlash('success', \sprintf('%s angelegt.', $code->getLabel())); + + return new HxRedirectResponse($this->generateUrl('app_admin_access_code')); + } + + return $this->render('admin/_modal_access_code.html.twig', [ + 'form' => $form, + 'code' => $code, + ]); + } +} diff --git a/src/Controller/Admin/AccessCode/DeleteController.php b/src/Controller/Admin/AccessCode/DeleteController.php new file mode 100644 index 0000000..bcc4506 --- /dev/null +++ b/src/Controller/Admin/AccessCode/DeleteController.php @@ -0,0 +1,57 @@ +isMethod(Request::METHOD_POST)) { + return $this->render('admin/_modal_access_code_delete.html.twig', [ + 'code' => $code, + 'csrf_token_id' => 'delete-access-code-'.$code->getId(), + ]); + } + + $this->denyUnlessCsrfValid($request, 'delete-access-code-'.$code->getId()); + + $this->audit->accessCodeDeleted((int) $code->getId(), $code->getLabel(), $code->getVisibility()); + + $label = $code->getLabel(); + $this->em->remove($code); + $this->em->flush(); + + $this->addFlash('success', \sprintf('%s gelöscht.', $label)); + + return new HxRedirectResponse($this->generateUrl('app_admin_access_code')); + } +} diff --git a/src/Controller/Admin/AccessCode/EditController.php b/src/Controller/Admin/AccessCode/EditController.php new file mode 100644 index 0000000..689b61e --- /dev/null +++ b/src/Controller/Admin/AccessCode/EditController.php @@ -0,0 +1,55 @@ +getCode(); + $previousVisibility = $code->getVisibility(); + + $form = $this->createForm(AccessCodeFormType::class, $code, [ + 'action' => $this->generateUrl('app_admin_access_code_edit', ['id' => $code->getId()]), + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->em->flush(); + + $this->audit->accessCodeUpdated($code, $previousCode !== $code->getCode(), $previousVisibility); + $this->addFlash('success', \sprintf('%s gespeichert.', $code->getLabel())); + + return new HxRedirectResponse($this->generateUrl('app_admin_access_code')); + } + + return $this->render('admin/_modal_access_code.html.twig', [ + 'form' => $form, + 'code' => $code, + ]); + } +} diff --git a/src/Controller/Admin/AccessCode/IndexController.php b/src/Controller/Admin/AccessCode/IndexController.php new file mode 100644 index 0000000..a915367 --- /dev/null +++ b/src/Controller/Admin/AccessCode/IndexController.php @@ -0,0 +1,77 @@ +render('admin/access_codes.html.twig', [ + 'rows' => $this->rows($this->codes->findReadableBy($this->currentUser()->getRoles()), $today), + ]); + } + + /** + * One row per code, each told what it is in relation to the others for the same door. + * + * The repository hands them over grouped by label, newest first, so the current code of a + * door is the first one in its group that has come into force: everything above it is still + * to come, everything below it has been replaced. A door whose code has never been rotated + * has a group of one, which is the ordinary case and needs no special handling. + * + * @param list $codes + * + * @return list + */ + private function rows(array $codes, \DateTimeImmutable $today): array + { + $rows = []; + $currentSeen = []; + + foreach ($codes as $code) { + $upcoming = $code->isUpcoming($today); + $label = $code->getLabel(); + $isCurrent = !$upcoming && !isset($currentSeen[$label]); + + if ($isCurrent) { + $currentSeen[$label] = true; + } + + $rows[] = [ + 'code' => $code, + 'upcoming' => $upcoming, + 'superseded' => !$upcoming && !$isCurrent, + ]; + } + + return $rows; + } +} diff --git a/src/Entity/AccessCode.php b/src/Entity/AccessCode.php new file mode 100644 index 0000000..81e8874 --- /dev/null +++ b/src/Entity/AccessCode.php @@ -0,0 +1,131 @@ +label = $label; + $this->code = $code; + $this->validFrom = $validFrom; + $this->description = $description; + $this->visibility = $visibility; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getLabel(): string + { + return $this->label; + } + + public function setLabel(string $label): void + { + $this->label = $label; + } + + public function getCode(): string + { + return $this->code; + } + + public function setCode(string $code): void + { + $this->code = $code; + } + + public function getValidFrom(): \DateTimeImmutable + { + return $this->validFrom; + } + + public function setValidFrom(\DateTimeImmutable $validFrom): void + { + $this->validFrom = $validFrom; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } + + public function getVisibility(): CodeVisibility + { + return $this->visibility; + } + + public function setVisibility(CodeVisibility $visibility): void + { + $this->visibility = $visibility; + } + + /** Not yet in force: it is listed so nobody is surprised on the day it takes over. */ + public function isUpcoming(\DateTimeImmutable $on): bool + { + return $this->validFrom > $on; + } +} diff --git a/src/Enum/CodeVisibility.php b/src/Enum/CodeVisibility.php new file mode 100644 index 0000000..1209241 --- /dev/null +++ b/src/Enum/CodeVisibility.php @@ -0,0 +1,71 @@ + 'Alle Mitarbeitenden', + self::ADMIN => 'Administration', + }; + } + + /** Worth marking in the list: not everyone who can see the page can see this row. */ + public function isRestricted(): bool + { + return self::EVERYONE !== $this; + } + + /** + * The roles this application knows are the ones in Role::ELIGIBLE, so "everyone" needs no + * role of its own: reaching the page at all already required one. + * + * @param list $roles + */ + public function isReadableBy(array $roles): bool + { + return match ($this) { + self::EVERYONE => true, + self::ADMIN => \in_array(Role::ADMIN, $roles, true), + }; + } + + /** + * The cases somebody with these roles may read, for the repository to filter on. + * + * @param list $roles + * + * @return list + */ + public static function readableBy(array $roles): array + { + return array_values(array_filter( + self::cases(), + static fn (self $visibility): bool => $visibility->isReadableBy($roles), + )); + } +} diff --git a/src/Form/Admin/AccessCodeFormType.php b/src/Form/Admin/AccessCodeFormType.php new file mode 100644 index 0000000..f831be5 --- /dev/null +++ b/src/Form/Admin/AccessCodeFormType.php @@ -0,0 +1,60 @@ + ''` for the reason ResourceGroupFormType's + * position does: their setters take a string, and a cleared field otherwise reaches them as null + * and fails with a TypeError before the NotBlank constraint ever gets to say so. + */ +final class AccessCodeFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('label', TextType::class, [ + 'empty_data' => '', + 'label' => 'Bezeichnung', + ]) + ->add('description', TextareaType::class, [ + 'label' => 'Beschreibung', + 'required' => false, + 'attr' => ['rows' => 2], + ]) + ->add('code', TextType::class, [ + 'empty_data' => '', + 'label' => 'Code', + ]) + ->add('validFrom', DateType::class, [ + 'label' => 'Gültig ab', + 'widget' => 'single_text', + ]) + ->add('visibility', EnumType::class, [ + 'label' => 'Für wen', + 'class' => CodeVisibility::class, + 'choice_label' => static fn (CodeVisibility $visibility): string => $visibility->label(), + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => AccessCode::class, + ]); + } +} diff --git a/src/Repository/AccessCodeRepository.php b/src/Repository/AccessCodeRepository.php new file mode 100644 index 0000000..e023958 --- /dev/null +++ b/src/Repository/AccessCodeRepository.php @@ -0,0 +1,70 @@ + + */ +class AccessCodeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AccessCode::class); + } + + /** + * The code that opens each door today, for the quick-look list. + * + * Dated-ahead codes are left in the database and out of the query: a code that does not open + * anything yet is not an answer to "what do I type", and showing it beside one that does is + * how somebody ends up typing the wrong one. Ordered so that the first row per label is the + * one in force -- the caller only has to take it. + * + * @param list $roles + * + * @return list + */ + public function findCurrentReadableBy(array $roles, \DateTimeImmutable $on): array + { + return $this->createQueryBuilder('c') + ->andWhere('c.visibility IN (:visibilities)') + ->andWhere('c.validFrom <= :on') + ->setParameter('visibilities', CodeVisibility::readableBy($roles)) + ->setParameter('on', $on) + ->orderBy('c.label', 'ASC') + ->addOrderBy('c.validFrom', 'DESC') + ->addOrderBy('c.id', 'DESC') + ->getQuery() + ->getResult(); + } + + /** + * Everything the holder of these roles may read, grouped by door and newest code first. + * + * The restriction is applied here rather than in the template, so a code somebody may not + * read is never loaded into a response in the first place -- a hidden table row is still a + * published code. + * + * @param list $roles + * + * @return list + */ + public function findReadableBy(array $roles): array + { + return $this->createQueryBuilder('c') + ->andWhere('c.visibility IN (:visibilities)') + ->setParameter('visibilities', CodeVisibility::readableBy($roles)) + ->orderBy('c.label', 'ASC') + ->addOrderBy('c.validFrom', 'DESC') + ->addOrderBy('c.id', 'DESC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Service/AuditLogger.php b/src/Service/AuditLogger.php index 33fb9b8..6d098c5 100644 --- a/src/Service/AuditLogger.php +++ b/src/Service/AuditLogger.php @@ -4,11 +4,13 @@ declare(strict_types=1); namespace App\Service; +use App\Entity\AccessCode; use App\Entity\Booking; use App\Entity\BookingSeries; use App\Entity\Resource; use App\Entity\ResourceGroup; use App\Entity\User; +use App\Enum\CodeVisibility; use App\Enum\ResourceType; use App\Model\SeriesResult; use App\Model\TimeRange; @@ -235,6 +237,49 @@ final readonly class AuditLogger ]); } + /** + * The code value itself is never logged, here or anywhere else. + * + * The audit trail is a file that whoever is investigating an incident reads, and a record of + * every door code ever set would turn it into a keyring -- one that outlives the codes, since + * the log is kept long after a code has been rotated away. What a record needs to answer is + * who changed which code and when, and that it can do while naming only the door. + */ + public function accessCodeCreated(AccessCode $code): void + { + $this->logger->info('Access code created.', $this->accessCode($code)); + } + + /** + * @param bool $codeChanged whether the secret itself was replaced, as opposed + * to a correction to the label or the date -- not + * derivable later, since the value is never logged + * @param CodeVisibility $previousVisibility spelled out for the same reason as a resource's + * `was_active`: widening a code from the + * administrators to the whole staff is the one edit + * here that changes who may read something + */ + public function accessCodeUpdated(AccessCode $code, bool $codeChanged, CodeVisibility $previousVisibility): void + { + $this->logger->info('Access code updated.', $this->accessCode($code) + [ + 'code_changed' => $codeChanged, + 'previous_visibility' => $previousVisibility->value, + ]); + } + + /** + * Takes a snapshot rather than the entity, as the other deletions do: afterwards there is no + * id left to read. + */ + public function accessCodeDeleted(int $id, string $label, CodeVisibility $visibility): void + { + $this->logger->info('Access code deleted.', [ + 'access_code' => $label, + 'access_code_id' => $id, + 'visibility' => $visibility->value, + ]); + } + /** * @return array */ @@ -275,6 +320,19 @@ final readonly class AuditLogger ]; } + /** + * @return array + */ + private function accessCode(AccessCode $code): array + { + return [ + 'access_code' => $code->getLabel(), + 'access_code_id' => $code->getId(), + 'valid_from' => $code->getValidFrom()->format('Y-m-d'), + 'visibility' => $code->getVisibility()->value, + ]; + } + /** * @return array */ diff --git a/templates/access_code/index.html.twig b/templates/access_code/index.html.twig new file mode 100644 index 0000000..674a272 --- /dev/null +++ b/templates/access_code/index.html.twig @@ -0,0 +1,59 @@ +{# + The quick look: what to type at a door, on the phone you are holding in front of it. + + One card per door with the code in force today and nothing else -- no dates, no history, no + buttons. Maintaining the codes is admin/access_codes.html.twig, which is the table this page + used to be. + + The card is sized for reading, not for fitting: the code is the largest thing on the screen, + in near-black on white and in a tabular font so 1 and 7 cannot be confused. One column on a + phone, more when there is room. +#} +{% extends 'layout.html.twig' %} + +{% block title %}Zugangscodes · E&P Platz{% endblock %} + +{% block content %} + ← Übersicht + +
+
+

Zugangscodes

+

Türen, Aufzug etc.

+
+ {% if is_granted('ROLE_ADMIN') %} + Codes verwalten + {% endif %} +
+ + {% if codes is empty %} +

Zurzeit gilt kein Code.

+ {% else %} +
+ {% for code in codes %} +
+
+ {{ code.label }} + {% if code.visibility.isRestricted %} + {{ code.visibility.label }} + {% endif %} +
+ + {% if code.description %} +

{{ code.description }}

+ {% endif %} + + {# mt-auto, so the code sits at the bottom of the card and the codes of a row + line up whether or not a door has a description -- grid stretches the cards + of a row to the same height, and this spends the difference above the code + rather than below it. Same device as the dashboard tiles. + + tracking-widest so a long code stays readable as digits rather than as a + number, and break-all so it never overflows a narrow screen. #} +

{{ code.code }}

+
+ {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/templates/admin/_modal_access_code.html.twig b/templates/admin/_modal_access_code.html.twig new file mode 100644 index 0000000..57c1014 --- /dev/null +++ b/templates/admin/_modal_access_code.html.twig @@ -0,0 +1,37 @@ +{# Creating and editing an access code. See admin/_modal_resource.html.twig for how it closes. #} +{% extends 'htmx_modal.html.twig' %} + +{% block title %} + {%- if code.id -%} + {{ code.label }} bearbeiten + {%- else -%} + Code hinzufügen + {%- endif -%} +{% endblock %} + +{% block content %} + {{ form_start(form, { attr: { + 'hx-post': form.vars.action, + 'hx-target': '#htmx-modal', + 'hx-swap': 'outerHTML', + } }) }} + {{ form_errors(form) }} + +
+ {{ form_row(form.label) }} + {{ form_row(form.description) }} + {{ form_row(form.code) }} + {{ form_row(form.validFrom) }} + {{ form_row(form.visibility) }} +
+ +
+ + +
+ {{ form_end(form) }} +{% endblock %} diff --git a/templates/admin/_modal_access_code_delete.html.twig b/templates/admin/_modal_access_code_delete.html.twig new file mode 100644 index 0000000..0869971 --- /dev/null +++ b/templates/admin/_modal_access_code_delete.html.twig @@ -0,0 +1,12 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block title %}Code löschen?{% endblock %} + +{% block content %} +

+ Der Code für {{ code.label }} (gültig ab + {{ code.validFrom|format_date(pattern: 'd.MM.y') }}) wird gelöscht. +

+{% endblock %} + +{% block button_confirm %}Löschen{% endblock %} diff --git a/templates/admin/access_codes.html.twig b/templates/admin/access_codes.html.twig new file mode 100644 index 0000000..9ed73f2 --- /dev/null +++ b/templates/admin/access_codes.html.twig @@ -0,0 +1,82 @@ +{# + Maintaining the codes: every row there is, in a table, newest per door first. + + The counterpart of access_code/index.html.twig, which shows one card per door with the code + that works today and nothing else. Everything the quick look leaves out is here -- rows the + controller marked `upcoming` or `superseded`, muted and labelled -- because this is the + screen on which a rotation is planned and checked. + + „Für wen" is the visibility itself: it decides who is served the row and says so in the same + breath, so there is nothing to keep in step between the two. +#} +{% extends 'layout.html.twig' %} + +{% block title %}Zugangscodes verwalten · E&P Platz{% endblock %} + +{% block content %} + ← Zugangscodes + +
+
+

Zugangscodes verwalten

+
+ +
+ + {% if rows is empty %} +

Noch nichts hinterlegt.

+ {% else %} +
+ + + + + + + + + + + + + {% for row in rows %} + {% set muted = row.upcoming or row.superseded %} + + + + + + + + + {% endfor %} + +
BezeichnungBeschreibungCodesichtbar fürgültig abAktionen
{{ row.code.label }}{{ row.code.description }}{{ row.code.code }} + {% if row.code.visibility.isRestricted %} + {{ row.code.visibility.label }} + {% else %} + {{ row.code.visibility.label }} + {% endif %} + + {{ row.code.validFrom|format_date(pattern: 'd.MM.y') }} + {% if row.upcoming %} + noch nicht gültig + {% elseif row.superseded %} + ersetzt + {% endif %} + +
+ + +
+
+
+ {% endif %} +{% endblock %} diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig index e2d8aa6..9c696c2 100644 --- a/templates/dashboard/index.html.twig +++ b/templates/dashboard/index.html.twig @@ -49,6 +49,15 @@

Alle Buchungen verwalten.

+ +
+ {{ ux_icon('lucide:key-round', { class: 'h-6 w-6 shrink-0 text-primary' }) }} + Zugangscodes +
+

Codes für Türen, Aufzug und Schranke.

+
+ {# The header is deliberately not navigation, so the dashboard is the only place an admin screen can be reached from. diff --git a/tests/Controller/AccessCodeControllerTest.php b/tests/Controller/AccessCodeControllerTest.php new file mode 100644 index 0000000..907f1a3 --- /dev/null +++ b/tests/Controller/AccessCodeControllerTest.php @@ -0,0 +1,182 @@ +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 testItIsBehindTheFirewall(): void + { + $this->client->request('GET', '/access-codes'); + + self::assertResponseRedirects(); + } + + public function testAnEmployeeSeesACardPerDoor(): void + { + $this->code('Aufzug', '4711', description: 'Kabine im Erdgeschoss'); + $this->code('Schranke', '1234'); + $this->em->flush(); + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $cards = $this->client->request('GET', '/access-codes')->filter('[data-access-code]'); + + self::assertResponseIsSuccessful(); + self::assertCount(2, $cards); + self::assertStringContainsString('Aufzug', $cards->eq(0)->text()); + self::assertStringContainsString('Kabine im Erdgeschoss', $cards->eq(0)->text()); + self::assertSame('4711', $cards->eq(0)->filter('[data-code]')->text()); + self::assertSame('1234', $cards->eq(1)->filter('[data-code]')->text()); + } + + /** + * The restriction is a filter on the query, not a hidden card: a code an employee may not + * read must not be in the response at all. + */ + public function testAnAdminOnlyCodeIsNotSentToAnEmployee(): void + { + $this->code('Serverraum', '9999', visibility: CodeVisibility::ADMIN); + $this->em->flush(); + + $this->client->loginUser($this->user('anna@ep-reisen.de')); + $crawler = $this->client->request('GET', '/access-codes'); + self::assertCount(0, $crawler->filter('[data-access-code]')); + self::assertStringNotContainsString('9999', (string) $this->client->getResponse()->getContent()); + + $this->client->loginUser($this->user('chef@ep-reisen.de', ['ROLE_ADMIN'])); + $card = $this->client->request('GET', '/access-codes')->filter('[data-access-code]'); + self::assertCount(1, $card); + self::assertSame('9999', $card->filter('[data-code]')->text()); + self::assertStringContainsString('Nur Verwaltung', $card->text()); + } + + /** + * Rotating a code. Only the one in force is shown: its predecessor is history, and its + * successor does not open anything yet -- both would only be another number to mistype. + */ + public function testOnlyTheCodeInForceIsShown(): void + { + $this->code('Aufzug', 'OLD', validFrom: '-2 months'); + $this->code('Aufzug', 'NOW', validFrom: '-1 day'); + $this->code('Aufzug', 'NEXT', validFrom: '+1 month'); + $this->em->flush(); + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $cards = $this->client->request('GET', '/access-codes')->filter('[data-access-code]'); + + self::assertCount(1, $cards); + self::assertSame('NOW', $cards->filter('[data-code]')->text()); + $body = (string) $this->client->getResponse()->getContent(); + self::assertStringNotContainsString('OLD', $body); + self::assertStringNotContainsString('NEXT', $body); + } + + /** + * A code that starts on a date starts that morning, not the day after. + */ + public function testACodeStartingTodayIsInForce(): void + { + $this->code('Aufzug', '4711', validFrom: 'today'); + $this->em->flush(); + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $cards = $this->client->request('GET', '/access-codes')->filter('[data-access-code]'); + + self::assertCount(1, $cards); + self::assertSame('4711', $cards->filter('[data-code]')->text()); + } + + /** + * A door that only has a code for next week is simply not on the page yet. + */ + public function testADoorWithNoCodeInForceIsAbsent(): void + { + $this->code('Schranke', 'SOON', validFrom: '+1 week'); + $this->em->flush(); + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $crawler = $this->client->request('GET', '/access-codes'); + + self::assertCount(0, $crawler->filter('[data-access-code]')); + self::assertStringContainsString('Zurzeit gilt kein Code', $crawler->filter('body')->text()); + } + + public function testOnlyAnAdminIsOfferedTheTable(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + self::assertCount(0, $this->client->request('GET', '/access-codes')->filter('a[href="/admin/access-codes"]')); + + $this->client->loginUser($this->user('chef@ep-reisen.de', ['ROLE_ADMIN'])); + self::assertCount(1, $this->client->request('GET', '/access-codes')->filter('a[href="/admin/access-codes"]')); + } + + public function testTheDashboardLinksHereForEveryone(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/access-codes"]')); + } + + private function code( + string $label, + string $value, + string $validFrom = 'today', + ?string $description = null, + CodeVisibility $visibility = CodeVisibility::EVERYONE, + ): AccessCode { + $code = new AccessCode($label, $value, new \DateTimeImmutable($validFrom), $description, $visibility); + $this->em->persist($code); + + return $code; + } + + /** + * @param list $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; + } +} diff --git a/tests/Controller/Admin/AccessCodeAdminControllerTest.php b/tests/Controller/Admin/AccessCodeAdminControllerTest.php new file mode 100644 index 0000000..88b3bde --- /dev/null +++ b/tests/Controller/Admin/AccessCodeAdminControllerTest.php @@ -0,0 +1,291 @@ +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('anna@ep-reisen.de')); + + $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())); + } + + /** + * 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())); + } + + /** + * Opens the modal, then posts the given fields with its CSRF token -- the round trip a + * browser makes. + * + * @param array $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('chef@ep-reisen.de', ['ROLE_ADMIN']); + } + + /** + * @param list $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; + } +} diff --git a/tests/Service/AuditLoggerTest.php b/tests/Service/AuditLoggerTest.php index 969ca79..500e940 100644 --- a/tests/Service/AuditLoggerTest.php +++ b/tests/Service/AuditLoggerTest.php @@ -4,11 +4,13 @@ declare(strict_types=1); namespace App\Tests\Service; +use App\Entity\AccessCode; use App\Entity\Booking; use App\Entity\BookingSeries; use App\Entity\Resource; use App\Entity\ResourceGroup; use App\Entity\User; +use App\Enum\CodeVisibility; use App\Enum\RecurrenceRule; use App\Enum\ResourceType; use App\Model\SeriesResult; @@ -152,6 +154,32 @@ class AuditLoggerTest extends TestCase self::assertSame('holder@ep-reisen.de', $records[1]->context['account']); } + /** + * The one thing the trail must never carry. It is asserted rather than left to review, + * because a helpful "log what changed" added later would leak every door code in the + * building into a file that outlives the codes themselves. + */ + public function testAnAccessCodeIsRecordedWithoutItsValue(): void + { + $code = new AccessCode('Aufzug', '4711', new \DateTimeImmutable('2026-10-01')); + + $this->audit->accessCodeCreated($code); + $this->audit->accessCodeUpdated($code, true, CodeVisibility::ADMIN); + $this->audit->accessCodeDeleted(7, 'Aufzug', CodeVisibility::EVERYONE); + + foreach ($this->handler->getRecords() as $record) { + self::assertStringNotContainsString('4711', json_encode($record->context, \JSON_THROW_ON_ERROR)); + } + + $records = $this->handler->getRecords(); + self::assertSame('Aufzug', $records[0]->context['access_code']); + self::assertSame('2026-10-01', $records[0]->context['valid_from']); + self::assertSame('everyone', $records[0]->context['visibility']); + self::assertTrue($records[1]->context['code_changed']); + self::assertSame('admin', $records[1]->context['previous_visibility']); + self::assertSame(7, $records[2]->context['access_code_id']); + } + private function record(): LogRecord { return $this->handler->getRecords()[0];