feat: add supervisor role

This commit is contained in:
fromme committed 2026-10-05 17:10:02 +02:00
1 parent dcd85bb911
commit 3af326251d
22 files changed
+270 -103

No files matched your search

@@ -82,6 +82,46 @@ class AccessCodeControllerTest extends WebTestCase
self::assertStringContainsString('Administration', $card->text());
}
/**
* A supervisor code is read by supervisors and by administrators, who hold no
* ROLE_SUPERVISOR of their own -- role_hierarchy does not reach the stored roles the query
* filters on. And a supervisor is not an administrator: the admin-only code stays out of
* their response.
*/
public function testASupervisorCodeIsReadBySupervisorsAndAdminsOnly(): void
{
$this->code('Management office', '5555', visibility: CodeVisibility::SUPERVISOR);
$this->code('Serverraum', '9999', visibility: CodeVisibility::ADMIN);
$this->em->flush();
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/access-codes');
self::assertStringNotContainsString('5555', (string) $this->client->getResponse()->getContent());
$this->client->loginUser($this->user('[email protected]', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
$card = $this->client->request('GET', '/access-codes')->filter('[data-access-code]');
self::assertCount(1, $card);
self::assertSame('5555', $card->filter('[data-code]')->text());
// The visibility caption the page shows, which is German like all user-facing text.
self::assertStringContainsString('Leitung', $card->text());
self::assertStringNotContainsString('9999', (string) $this->client->getResponse()->getContent());
$this->client->loginUser($this->user('[email protected]', ['ROLE_ADMIN']));
self::assertCount(2, $this->client->request('GET', '/access-codes')->filter('[data-access-code]'));
}
/**
* A supervisor reads codes but does not manage them.
*/
public function testASupervisorIsNotOfferedTheTable(): void
{
$this->client->loginUser($this->user('[email protected]', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
$crawler = $this->client->request('GET', '/access-codes');
self::assertCount(0, $crawler->filter('a[href="/admin/access-codes"]'));
}
/**
* 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.
@@ -2,24 +2,29 @@
declare(strict_types=1);
namespace App\Tests\Controller\Admin;
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 admin area, and the widened ownership checks it leans on.
* 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 BookingAdminControllerTest extends WebTestCase
class BookingManageControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $em;
@@ -38,21 +43,21 @@ class BookingAdminControllerTest extends WebTestCase
parent::tearDown();
}
public function testAnEmployeeCannotReachTheAdminList(): void
public function testAnEmployeeCannotReachTheOverview(): void
{
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/admin/bookings');
$this->client->request('GET', '/manage/bookings');
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testTheAdminListShowsOtherPeoplesBookings(): void
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', '/admin/bookings');
$crawler = $this->client->request('GET', '/manage/bookings');
self::assertResponseIsSuccessful();
self::assertStringContainsString('Bea Berger', $crawler->filter('body')->text());
@@ -66,15 +71,15 @@ class BookingAdminControllerTest extends WebTestCase
// 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)));
$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=admin', $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('/admin/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
self::assertSame('/manage/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
$this->em->clear();
self::assertNull($this->em->getRepository(Booking::class)->find($id));
}
@@ -95,7 +100,7 @@ class BookingAdminControllerTest extends WebTestCase
// 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',
'released by the person holding it or by a supervisor',
(string) $this->client->getResponse()->getContent(),
);
self::assertNotNull($this->em->getRepository(Booking::class)->find($id));
@@ -122,7 +127,7 @@ class BookingAdminControllerTest extends WebTestCase
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId());
$crawler = $this->client->request('GET', '/manage/bookings?user='.$bea->getId());
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
@@ -136,7 +141,7 @@ class BookingAdminControllerTest extends WebTestCase
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('P1', ResourceType::PARKING));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings?type=parking');
$crawler = $this->client->request('GET', '/manage/bookings?type=parking');
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
@@ -156,11 +161,11 @@ class BookingAdminControllerTest extends WebTestCase
$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*="/admin/spot/"]'));
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="/admin/spot/%d/%s"]', $desk->getId(), $date)));
self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/manage/spot/%d/%s"]', $desk->getId(), $date)));
}
public function testTheSpotModalListsTheHolderAndReleasesTheBooking(): void
@@ -171,7 +176,7 @@ class BookingAdminControllerTest extends WebTestCase
$crawler = $this->client->request(
'GET',
\sprintf('/admin/spot/%d/%s', $desk->getId(), $booking->getDate()->format('Y-m-d')),
\sprintf('/manage/spot/%d/%s', $desk->getId(), $booking->getDate()->format('Y-m-d')),
server: ['HTTP_HX-Request' => 'true'],
);
@@ -192,7 +197,7 @@ class BookingAdminControllerTest extends WebTestCase
$crawler = $this->client->request(
'GET',
\sprintf('/admin/spot/%d/%s', $desk->getId(), (new \DateTimeImmutable('today'))->format('Y-m-d')),
\sprintf('/manage/spot/%d/%s', $desk->getId(), (new \DateTimeImmutable('today'))->format('Y-m-d')),
server: ['HTTP_HX-Request' => 'true'],
);
@@ -201,13 +206,90 @@ class BookingAdminControllerTest extends WebTestCase
self::assertStringNotContainsString('Carl Conrad', $text);
}
public function testTheDashboardTileIsAdminOnly(): void
public function testTheDashboardTileIsForSupervisorsAndAdminsOnly(): void
{
$this->client->loginUser($this->user('[email protected]'));
self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/bookings"]'));
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="/admin/bookings"]'));
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);
}
}
/**
@@ -222,16 +304,16 @@ class BookingAdminControllerTest extends WebTestCase
$crawler = $this->client->request(
'GET',
\sprintf('/bookings/%d/release?return=admin', $booking->getId()),
\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=admin through the round trip.
// 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=admin', $booking->getId()),
\sprintf('https://localhost/bookings/%d/release?return=manage', $booking->getId()),
$modal->filter('[hx-post]')->attr('hx-post'),
);
}
@@ -250,12 +332,12 @@ class BookingAdminControllerTest extends WebTestCase
}
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings');
$crawler = $this->client->request('GET', '/manage/bookings');
self::assertResponseIsSuccessful();
self::assertCount(25, $crawler->filter('tbody tr'));
$crawler = $this->client->request('GET', '/admin/bookings?page=2');
$crawler = $this->client->request('GET', '/manage/bookings?page=2');
self::assertResponseIsSuccessful();
self::assertCount(5, $crawler->filter('tbody tr'));
@@ -272,7 +354,7 @@ class BookingAdminControllerTest extends WebTestCase
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId().'&sort=u.name&direction=desc');
$crawler = $this->client->request('GET', '/manage/bookings?user='.$bea->getId().'&sort=u.name&direction=desc');
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
@@ -299,6 +381,11 @@ class BookingAdminControllerTest extends WebTestCase
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']);