Türen, Aufzug etc.
+Zurzeit gilt kein Code.
+ {% else %} +{{ 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 }}
++ 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 + +Noch nichts hinterlegt.
+ {% else %} +| Bezeichnung | +Beschreibung | +Code | +sichtbar für | +gültig ab | +Aktionen | +
|---|---|---|---|---|---|
| {{ 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 %} + | +
+
+
+
+
+ |
+
Alle Buchungen verwalten.
+ +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