feat: add supervisor role
This commit is contained in:
1 parent
dcd85bb911
commit
3af326251d
22 files changed
+270
-103
No files matched your search
@@ -0,0 +1,415 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Manage;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\User;
|
||||
use App\Enum\RecurrenceRule;
|
||||
use App\Enum\ResourceType;
|
||||
use App\Model\TimeRange;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The overview of everybody's bookings, and the widened ownership checks it leans on. Most tests
|
||||
* sign in as an administrator, who reaches it through role_hierarchy; the supervisor ones below
|
||||
* pin down that the role itself is enough, and that it reaches no further than this.
|
||||
*
|
||||
* The test that matters most here is the negative one: releasing somebody else's booking must
|
||||
* still be refused for an ordinary employee. Everything else on this screen is a list.
|
||||
*/
|
||||
class BookingManageControllerTest 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 testAnEmployeeCannotReachTheOverview(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$this->client->request('GET', '/manage/bookings');
|
||||
|
||||
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testTheOverviewShowsOtherPeoplesBookings(): void
|
||||
{
|
||||
$this->booking($this->user('[email protected]', '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('[email protected]', '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('[email protected]'), $this->resource('D1'));
|
||||
$id = $booking->getId();
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$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('[email protected]'), $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('[email protected]', 'Bea Berger');
|
||||
$this->booking($bea, $this->resource('D1'));
|
||||
$this->booking($this->user('[email protected]', '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('[email protected]', 'Bea Berger'), $this->resource('D1'));
|
||||
$this->booking($this->user('[email protected]', '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('[email protected]', 'Bea Berger'), $desk);
|
||||
$date = (new \DateTimeImmutable('today'))->format('Y-m-d');
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$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('[email protected]', '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('[email protected]', 'Bea Berger'), $desk);
|
||||
$this->booking($this->user('[email protected]', '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('[email protected]'));
|
||||
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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', 'Bea Berger');
|
||||
$this->booking($bea, $this->resource('D1'));
|
||||
$this->booking($this->user('[email protected]', '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 = '[email protected]', string $name = 'Sam Supervisor'): User
|
||||
{
|
||||
return $this->user($email, $name, ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']);
|
||||
}
|
||||
|
||||
private function admin(string $email = '[email protected]', string $name = 'Chef Chefin'): User
|
||||
{
|
||||
return $this->user($email, $name, ['ROLE_ADMIN']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user