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 testAnEmployeeCannotReachTheOverview(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $this->client->request('GET', '/manage/bookings'); self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode()); } public function testTheOverviewShowsOtherPeoplesBookings(): void { $this->booking($this->user('bea@ep-reisen.de', 'Bea Berger'), $this->resource('D1')); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/manage/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', '/manage/bookings'); self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release?return=manage"]', $id))); $uri = \sprintf('/bookings/%d/release?return=manage', $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('/manage/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 a supervisor', (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', '/manage/bookings?user='.$bea->getId()); self::assertResponseIsSuccessful(); $text = $crawler->filter('tbody')->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', '/manage/bookings?type=parking'); self::assertResponseIsSuccessful(); $text = $crawler->filter('tbody')->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*="/manage/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="/manage/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('/manage/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('/manage/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 testTheDashboardTileIsForSupervisorsAndAdminsOnly(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/manage/bookings"]')); $this->client->loginUser($this->supervisor()); $crawler = $this->client->request('GET', '/'); self::assertCount(1, $crawler->filter('a[href="/manage/bookings"]')); // The tile beside it leads into /admin, which a supervisor cannot enter. self::assertCount(0, $crawler->filter('a[href="/admin/resources"]')); $this->client->loginUser($this->admin()); self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/manage/bookings"]')); } public function testASupervisorSeesTheOverview(): void { $this->booking($this->user('bea@ep-reisen.de', 'Bea Berger'), $this->resource('D1')); $this->client->loginUser($this->supervisor()); $crawler = $this->client->request('GET', '/manage/bookings'); self::assertResponseIsSuccessful(); self::assertStringContainsString('Bea Berger', $crawler->filter('body')->text()); } public function testASupervisorReleasesABookingOnBehalfOfSomebodyElse(): void { $booking = $this->booking($this->user('bea@ep-reisen.de', 'Bea Berger'), $this->resource('D1')); $id = $booking->getId(); $this->client->loginUser($this->supervisor()); $uri = \sprintf('/bookings/%d/release?return=manage', $id); $this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']); self::assertSame('/manage/bookings', $this->client->getResponse()->headers->get('HX-Redirect')); $this->em->clear(); self::assertNull($this->em->getRepository(Booking::class)->find($id)); } public function testASupervisorEndsSomebodyElsesSeries(): void { $series = new BookingSeries( $this->user('bea@ep-reisen.de', 'Bea Berger'), $this->resource('D1'), RecurrenceRule::DAILY, new \DateTimeImmutable('+1 day'), new \DateTimeImmutable('+2 days'), TimeRange::wholeDay(), ); $this->em->persist($series); $this->em->flush(); $this->client->loginUser($this->supervisor()); $uri = \sprintf('/series/%d/end?return=manage', $series->getId()); $this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']); self::assertSame('/manage/bookings', $this->client->getResponse()->headers->get('HX-Redirect')); } public function testAnOccupiedDeskIsClickableForASupervisor(): 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->supervisor()); $crawler = $this->client->request('GET', '/book/desk?date='.$date); self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/manage/spot/%d/%s"]', $desk->getId(), $date))); } /** * A supervisor frees bookings; configuring what can be booked and who reads which code stays * with the administrators. */ public function testASupervisorCannotEnterTheAdminArea(): void { $this->client->loginUser($this->supervisor()); foreach (['/admin/resources', '/admin/access-codes'] as $path) { $this->client->request('GET', $path); self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode(), $path); } } /** * 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=manage', $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=manage through the round trip. self::assertSame( \sprintf('https://localhost/bookings/%d/release?return=manage', $booking->getId()), $modal->filter('[hx-post]')->attr('hx-post'), ); } /** * The list is paginated, so the window no longer decides how much gets rendered. Page two is * the part of the assertion that matters: the first page could be right by accident if the * limit were applied without an offset. */ public function testTheListIsPaginated(): void { $user = $this->user('bea@ep-reisen.de', 'Bea Berger'); $desk = $this->resource('D1'); for ($day = 0; $day < 30; ++$day) { $this->booking($user, $desk, new \DateTimeImmutable('today +'.$day.' days')); } $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/manage/bookings'); self::assertResponseIsSuccessful(); self::assertCount(25, $crawler->filter('tbody tr')); $crawler = $this->client->request('GET', '/manage/bookings?page=2'); self::assertResponseIsSuccessful(); self::assertCount(5, $crawler->filter('tbody tr')); } /** * Sorting has to survive next to the filter: both live in the query string, and a sort link * that dropped the filter would silently widen what is being paged through. */ public function testAColumnHeaderSortsWithoutLosingTheFilter(): 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', '/manage/bookings?user='.$bea->getId().'&sort=u.name&direction=desc'); self::assertResponseIsSuccessful(); $text = $crawler->filter('tbody')->text(); self::assertStringContainsString('Bea Berger', $text); self::assertStringNotContainsString('Carl Conrad', $text); } 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, ?\DateTimeImmutable $date = null): Booking { $booking = new Booking($user, $resource, $date ?? new \DateTimeImmutable('today')); $this->em->persist($booking); $this->em->flush(); return $booking; } private function supervisor(string $email = 'supervisor@ep-reisen.de', string $name = 'Sam Supervisor'): User { return $this->user($email, $name, ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']); } 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; } }