client = self::createClient(); $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 testAnEmployeeCannotReachTheAdminList(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $this->client->request('GET', '/admin/bookings'); self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode()); } public function testTheAdminListShowsOtherPeoplesBookings(): void { $this->booking($this->user('bea@ep-reisen.de', 'Bea Berger'), $this->resource('D1')); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/bookings'); self::assertResponseIsSuccessful(); self::assertStringContainsString('Bea Berger', $crawler->filter('body')->text()); } public function testAnAdminReleasesABookingOnBehalfOfSomebodyElse(): void { $booking = $this->booking($this->user('bea@ep-reisen.de', 'Bea Berger'), $this->resource('D1')); $id = $booking->getId(); $this->client->loginUser($this->admin()); // The two-step flow a browser walks: the list opens the confirmation modal, the modal // carries the token and posts back to the same URI. $crawler = $this->client->request('GET', '/admin/bookings'); self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release?return=admin"]', $id))); $uri = \sprintf('/bookings/%d/release?return=admin', $id); $this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']); // ?return= means a genuine navigation rather than a board swap: this page has no board // fragments, so HX-Redirect is the honest answer. self::assertSame('/admin/bookings', $this->client->getResponse()->headers->get('HX-Redirect')); $this->em->clear(); self::assertNull($this->em->getRepository(Booking::class)->find($id)); } /** * The regression that matters: widening the check for admins must not have widened it for * everybody. */ public function testAnEmployeeStillCannotReleaseSomebodyElsesBooking(): void { $booking = $this->booking($this->user('bea@ep-reisen.de'), $this->resource('D1')); $id = $booking->getId(); $this->client->loginUser($this->user('anna@ep-reisen.de')); $this->client->request('POST', \sprintf('/bookings/%d/release', $id), ['_token' => 'whatever']); self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode()); // The ownership guard runs before the CSRF one, so asserting on its message is what // distinguishes "refused because it is not yours" from "refused because of the token". self::assertStringContainsString( 'released by the person holding it or by an administrator', (string) $this->client->getResponse()->getContent(), ); self::assertNotNull($this->em->getRepository(Booking::class)->find($id)); } /** * Admins release; they do not reschedule. Moving somebody else's booking is a different act * and was deliberately left alone. */ public function testAnAdminStillCannotEditSomebodyElsesBooking(): void { $booking = $this->booking($this->user('bea@ep-reisen.de'), $this->resource('D1')); $this->client->loginUser($this->admin()); $this->client->request('GET', \sprintf('/bookings/%d/edit', $booking->getId())); self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode()); } public function testTheListNarrowsToOnePerson(): void { $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); $this->booking($bea, $this->resource('D1')); $this->booking($this->user('carl@ep-reisen.de', 'Carl Conrad'), $this->resource('D2')); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId()); self::assertResponseIsSuccessful(); $text = $crawler->filter('ul')->text(); self::assertStringContainsString('Bea Berger', $text); self::assertStringNotContainsString('Carl Conrad', $text); } public function testTheListNarrowsToOneResourceType(): void { $this->booking($this->user('bea@ep-reisen.de', 'Bea Berger'), $this->resource('D1')); $this->booking($this->user('carl@ep-reisen.de', 'Carl Conrad'), $this->resource('P1', ResourceType::PARKING)); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/bookings?type=parking'); self::assertResponseIsSuccessful(); $text = $crawler->filter('ul')->text(); self::assertStringContainsString('Carl Conrad', $text); self::assertStringNotContainsString('Bea Berger', $text); } /** * The way into the admin modal from the board: a desk held by a colleague is inert for an * employee and clickable for an admin. */ public function testAnOccupiedDeskIsClickableForAnAdminOnly(): void { $desk = $this->resource('D1'); $this->booking($this->user('bea@ep-reisen.de', 'Bea Berger'), $desk); $date = (new \DateTimeImmutable('today'))->format('Y-m-d'); $this->client->loginUser($this->user('anna@ep-reisen.de')); $crawler = $this->client->request('GET', '/book/desk?date='.$date); self::assertCount(0, $crawler->filter('[data-spot][hx-get*="/admin/spot/"]')); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/book/desk?date='.$date); self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/admin/spot/%d/%s"]', $desk->getId(), $date))); } public function testTheSpotModalListsTheHolderAndReleasesTheBooking(): void { $desk = $this->resource('D1'); $booking = $this->booking($this->user('bea@ep-reisen.de', 'Bea Berger'), $desk); $this->client->loginUser($this->admin()); $crawler = $this->client->request( 'GET', \sprintf('/admin/spot/%d/%s', $desk->getId(), $booking->getDate()->format('Y-m-d')), server: ['HTTP_HX-Request' => 'true'], ); self::assertResponseIsSuccessful(); self::assertStringContainsString('Bea Berger', $crawler->filter('#htmx-modal')->text()); self::assertCount(1, $crawler->filter(\sprintf('button[hx-get="/bookings/%d/release"]', $booking->getId()))); } /** * The modal is scoped to the shape that was clicked, not to the whole day. */ public function testTheSpotModalShowsOnlyThatResourcesBookings(): void { $desk = $this->resource('D1'); $this->booking($this->user('bea@ep-reisen.de', 'Bea Berger'), $desk); $this->booking($this->user('carl@ep-reisen.de', 'Carl Conrad'), $this->resource('D2')); $this->client->loginUser($this->admin()); $crawler = $this->client->request( 'GET', \sprintf('/admin/spot/%d/%s', $desk->getId(), (new \DateTimeImmutable('today'))->format('Y-m-d')), server: ['HTTP_HX-Request' => 'true'], ); $text = $crawler->filter('#htmx-modal')->text(); self::assertStringContainsString('Bea Berger', $text); self::assertStringNotContainsString('Carl Conrad', $text); } public function testTheDashboardTileIsAdminOnly(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/bookings"]')); $this->client->loginUser($this->admin()); self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/bookings"]')); } /** * The confirmation modal names the holder, so an administrator cannot free the wrong desk * without seeing whose it is. This is the whole reason it exists rather than a * window.confirm. */ public function testTheConfirmationModalNamesTheHolder(): void { $booking = $this->booking($this->user('bea@ep-reisen.de', 'Bea Berger'), $this->resource('D1')); $this->client->loginUser($this->admin()); $crawler = $this->client->request( 'GET', \sprintf('/bookings/%d/release?return=admin', $booking->getId()), server: ['HTTP_HX-Request' => 'true'], ); self::assertResponseIsSuccessful(); $modal = $crawler->filter('#htmx-confirmation-modal'); self::assertStringContainsString('Bea Berger', $modal->text()); // Posts back to the same URI, which is what carries ?return=admin through the round trip. self::assertSame( \sprintf('http://localhost/bookings/%d/release?return=admin', $booking->getId()), $modal->filter('[hx-post]')->attr('hx-post'), ); } 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']; } private function booking(User $user, Resource $resource): Booking { $booking = new Booking($user, $resource, new \DateTimeImmutable('today')); $this->em->persist($booking); $this->em->flush(); return $booking; } private function admin(string $email = 'chef@ep-reisen.de', string $name = 'Chef Chefin'): User { return $this->user($email, $name, ['ROLE_ADMIN']); } /** * @param list $roles */ private function user(string $email, string $name = 'Test', array $roles = ['ROLE_EMPLOYEE']): User { $user = new User($email, $name); $user->setRoles($roles); $this->em->persist($user); $this->em->flush(); return $user; } private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource { $resource = new Resource($type, $label); $this->em->persist($resource); $this->em->flush(); return $resource; } }