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

+14 -4
View File
@@ -7,6 +7,13 @@ security:
# No password_hashers: there are no passwords here. Authentication is OAuth2 against myep,
# which owns the credentials; ephub only ever sees a userinfo response.
# An administrator can do everything a supervisor can, so the "may act on other people's
# bookings" checks ask for ROLE_SUPERVISOR alone. Only isGranted() sees this:
# User::getRoles() still returns the stored roles, which is why CodeVisibility names
# ROLE_ADMIN explicitly.
role_hierarchy:
ROLE_ADMIN: [ROLE_SUPERVISOR]
providers:
app_user_provider:
entity:
@@ -46,8 +53,11 @@ security:
# Before the catch-all, or ROLE_EMPLOYEE would match ^/ first and the admin area would be
# open to everybody. Order is the whole guard here.
- { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https }
# Either role opens the app -- access_control treats a list of roles as an OR. Not
# Everybody's bookings, for supervisors and (through role_hierarchy) administrators. Kept
# out of ^/admin so that everything under it stays administrator-only.
- { path: ^/manage, roles: ROLE_SUPERVISOR, requires_channel: https }
# Any of these opens the app -- access_control treats a list of roles as an OR. Not
# ROLE_USER: myep authenticates far more people than work here, and someone who holds
# neither of these is refused at login with an explanation rather than let in to an
# empty screen.
- { path: ^/, roles: [ROLE_EMPLOYEE, ROLE_ADMIN], requires_channel: https }
# none of these is refused at login with an explanation rather than let in to an empty
# screen. Keep in step with Role::ELIGIBLE.
- { path: ^/, roles: [ROLE_EMPLOYEE, ROLE_SUPERVISOR, ROLE_ADMIN], requires_channel: https }
@@ -23,7 +23,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
* Retiring is a checkbox here rather than a confirmation modal of its own: it deletes nothing and
* is undone by ticking the box again. What it does leave behind -- bookings that still point at
* the resource -- is named in the flash, so the administrator can release them from
* /admin/bookings if they should not stand.
* /manage/bookings if they should not stand.
*/
#[IsGranted(Role::ADMIN)]
final class EditController extends AbstractController
+5 -5
View File
@@ -56,7 +56,7 @@ final class ReleaseController extends AbstractController
$onBehalf = !$booking->belongsTo($this->currentUser());
// The release itself is audited from BookingManager, which is also where the row still
// exists to be described. An administrator freeing somebody else's desk needs nothing
// exists to be described. A supervisor freeing somebody else's desk needs nothing
// extra here: the record carries the holder, and the actor comes from the token.
$this->manager->release($booking);
@@ -78,13 +78,13 @@ final class ReleaseController extends AbstractController
}
/**
* Releasing is the one thing an administrator may do to a booking that is not theirs -- a
* colleague calls in sick and their desk has to go back into the pool.
* Releasing is the one thing a supervisor may do to a booking that is not theirs -- a colleague
* calls in sick and their desk has to go back into the pool. Administrators inherit it.
*/
private function denyUnlessMayManage(Booking $booking): void
{
if (!$booking->belongsTo($this->currentUser()) && !$this->isGranted(Role::ADMIN)) {
throw $this->createAccessDeniedException('A booking may only be released by the person holding it or by an administrator.');
if (!$booking->belongsTo($this->currentUser()) && !$this->isGranted(Role::SUPERVISOR)) {
throw $this->createAccessDeniedException('A booking may only be released by the person holding it or by a supervisor.');
}
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* The landing screen: one tile per bookable kind, plus tiles for the bookings list and, for an
* administrator, the admin screens.
* The landing screen: one tile per bookable kind, plus tiles for the bookings list, for a
* supervisor the overview of everybody's bookings and, for an administrator, the admin screens.
*
* It replaces the sidebar navigation, so it is also the only route to the booking boards and the
* bookings list -- which is why the tiles are generated from ResourceType rather than written
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Controller\Admin\Booking;
namespace App\Controller\Manage\Booking;
use App\Form\Admin\BookingFilter;
use App\Form\Admin\BookingFilterType;
use App\Form\Manage\BookingFilter;
use App\Form\Manage\BookingFilterType;
use App\Repository\BookingRepository;
use App\Repository\BookingSeriesRepository;
use App\Security\Role;
@@ -17,18 +17,22 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* What ROLE_ADMIN can do that ROLE_EMPLOYEE cannot: see everybody's bookings and free a resource
* on somebody's behalf -- a colleague calls in sick and their desk should go back into the pool.
* What a supervisor can do that ROLE_EMPLOYEE cannot: see everybody's bookings and free a
* resource on somebody's behalf -- a colleague calls in sick and their desk should go back into
* the pool. Administrators reach it through role_hierarchy.
*
* The admin controllers are read-only. The two actions offered here post to the ordinary
* app_booking_release / app_series_end routes, whose ownership checks were widened to admit an
* administrator, so there is exactly one code path that removes a booking and it still goes
* Not under /admin, which is administrator-only throughout: supervisors may free bookings but not
* configure resources or codes.
*
* The manage controllers are read-only. The two actions offered here post to the ordinary
* app_booking_release / app_series_end routes, whose ownership checks were widened to admit
* supervisors, so there is exactly one code path that removes a booking and it still goes
* through BookingManager.
*
* The access_control rule on ^/admin is the real gate; the attribute here is so the guard
* The access_control rule on ^/manage is the real gate; the attribute here is so the guard
* survives a change to the route path.
*/
#[IsGranted(Role::ADMIN)]
#[IsGranted(Role::SUPERVISOR)]
final class IndexController extends AbstractController
{
/**
@@ -44,7 +48,7 @@ final class IndexController extends AbstractController
) {
}
#[Route('/admin/bookings', name: 'app_admin_booking', methods: ['GET'])]
#[Route('/manage/bookings', name: 'app_manage_booking', methods: ['GET'])]
public function index(Request $request): Response
{
$filter = new BookingFilter();
@@ -62,7 +66,7 @@ final class IndexController extends AbstractController
self::PER_PAGE,
);
return $this->render('admin/bookings.html.twig', [
return $this->render('manage/bookings.html.twig', [
'form' => $form,
'filter' => $filter,
'pagination' => $pagination,
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Controller\Admin\Booking;
namespace App\Controller\Manage\Booking;
use App\Controller\Traits\BoardResponseTrait;
use App\Entity\Resource;
@@ -16,13 +16,13 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The modal behind an occupied shape on the floor plan, for an administrator.
* The modal behind an occupied shape on the floor plan, for supervisors and administrators.
*
* Not app_booking_edit: a shape can hold several bookings on one day once part-day spans exist,
* so "the booking" has no single referent -- and editing reschedules, which an administrator
* so "the booking" has no single referent -- and editing reschedules, which a supervisor
* deliberately may not do to somebody else's booking.
*/
#[IsGranted(Role::ADMIN)]
#[IsGranted(Role::SUPERVISOR)]
final class SpotController extends AbstractController
{
use BoardResponseTrait;
@@ -31,12 +31,12 @@ final class SpotController extends AbstractController
{
}
#[Route('/admin/spot/{id}/{date}', name: 'app_admin_spot', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])]
#[Route('/manage/spot/{id}/{date}', name: 'app_manage_spot', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])]
public function index(Request $request, Resource $resource, string $date): Response
{
$selected = DateParam::parseOrToday($date);
return $this->render('admin/_modal_spot.html.twig', [
return $this->render('manage/_modal_spot.html.twig', [
'resource' => $resource,
'date' => $selected,
// The board's span, so the modal lists exactly what the shape the user clicked shows.
+2 -2
View File
@@ -59,8 +59,8 @@ final class EndController extends AbstractController
private function denyUnlessMayManageSeries(BookingSeries $series): void
{
if ($series->getUser()->getId() !== $this->currentUser()->getId() && !$this->isGranted(Role::ADMIN)) {
throw $this->createAccessDeniedException('A series may only be ended by the person who created it or by an administrator.');
if ($series->getUser()->getId() !== $this->currentUser()->getId() && !$this->isGranted(Role::SUPERVISOR)) {
throw $this->createAccessDeniedException('A series may only be ended by the person who created it or by a supervisor.');
}
}
}
@@ -13,14 +13,14 @@ trait ConfirmedActionTrait
{
/**
* Where to navigate after a confirmed action, or null when the caller is the board and wants
* its fragments swapped instead. The opener says which -- the admin screen sends
* ?return=admin so freeing somebody else's desk does not drop the administrator on their own
* bookings.
* its fragments swapped instead. The opener says which -- the overview of everybody's
* bookings sends ?return=manage so freeing somebody else's desk does not drop a supervisor on
* their own bookings.
*/
private function returnRoute(Request $request): ?string
{
return match ($request->query->get('return')) {
'admin' => 'app_admin_booking',
'manage' => 'app_manage_booking',
'bookings' => 'app_booking',
default => null,
};
+12 -4
View File
@@ -9,10 +9,10 @@ use App\Security\Role;
/**
* Who may read an access code.
*
* Deliberately an enum with two cases rather than a boolean column: "admin only" is one policy
* among several plausible ones -- a code for the reception desk or for the cleaning company is
* the obvious next case -- and a third case here costs a case in the match below, where a third
* value of a boolean costs a migration.
* Deliberately an enum rather than a boolean column: "admin only" was one policy among several
* plausible ones -- supervisors were the next, a code for the reception desk or for the cleaning
* company the obvious ones after that -- and another case here costs a case in the match below,
* where another value of a boolean costs a migration.
*
* This is also what the list shows under "Für wen": the access decision and the caption are one
* value, so there is no second, prose field describing an audience that the filter does not
@@ -23,6 +23,9 @@ enum CodeVisibility: string
/** Every signed-in user, which in this application means everyone who works here. */
case EVERYONE = 'everyone';
/** Supervisors, and administrators with them -- e.g. the office of the management. */
case SUPERVISOR = 'supervisor';
/** Administrators only -- a code the whole staff has no business reading. */
case ADMIN = 'admin';
@@ -30,6 +33,7 @@ enum CodeVisibility: string
{
return match ($this) {
self::EVERYONE => 'Alle Mitarbeitenden',
self::SUPERVISOR => 'Leitung',
self::ADMIN => 'Administration',
};
}
@@ -44,12 +48,16 @@ enum CodeVisibility: string
* The roles this application knows are the ones in Role::ELIGIBLE, so "everyone" needs no
* role of its own: reaching the page at all already required one.
*
* These are the stored roles, which role_hierarchy does not expand -- so an administrator's
* claim to the supervisor codes is spelt out here rather than inherited.
*
* @param list<string> $roles
*/
public function isReadableBy(array $roles): bool
{
return match ($this) {
self::EVERYONE => true,
self::SUPERVISOR => \in_array(Role::SUPERVISOR, $roles, true) || \in_array(Role::ADMIN, $roles, true),
self::ADMIN => \in_array(Role::ADMIN, $roles, true),
};
}
@@ -2,14 +2,14 @@
declare(strict_types=1);
namespace App\Form\Admin;
namespace App\Form\Manage;
use App\Config\BookingHorizon;
use App\Entity\User;
use App\Enum\ResourceType;
/**
* What the admin booking list is narrowed by. A plain DTO bound to a GET form, so the filters
* What the overview of all bookings is narrowed by. A plain DTO bound to a GET form, so the filters
* live in the URL and a filtered view can be linked to or bookmarked.
*/
final class BookingFilter
@@ -24,7 +24,7 @@ final class BookingFilter
public function __construct()
{
// Today through the booking horizon: what an administrator can still do something about.
// Today through the booking horizon: what a supervisor can still do something about.
// Past bookings are history and are not actionable, so they are not the default view.
$this->from = new \DateTimeImmutable('today');
$this->to = BookingHorizon::latest();
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Form\Admin;
namespace App\Form\Manage;
use App\Entity\User;
use App\Enum\ResourceType;
+3 -3
View File
@@ -9,7 +9,7 @@ use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\User;
use App\Enum\ResourceType;
use App\Form\Admin\BookingFilter;
use App\Form\Manage\BookingFilter;
use App\Model\TimeRange;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
@@ -210,10 +210,10 @@ class BookingRepository extends ServiceEntityRepository
}
/**
* Every booking in a window, across everybody -- what the admin list renders.
* Every booking in a window, across everybody -- what /manage/bookings renders.
*
* findForUserInRange() is the same query with a mandatory user; this one keeps it optional
* and eager-selects the holder as well, because the admin list names a different person on
* and eager-selects the holder as well, because the overview names a different person on
* every row and would otherwise fire a query per booking.
*
* Unexecuted, because the paginator has to add the LIMIT and the ORDER BY of a clicked column
+2 -2
View File
@@ -30,9 +30,9 @@ class BookingSeriesRepository extends ServiceEntityRepository
/**
* Every series still running on the given day, optionally narrowed to one person or one kind
* of resource. The admin list is the caller that needs it across everybody.
* of resource. The overview at /manage/bookings is the caller that needs it across everybody.
*
* The user is eager-selected because the admin list names the holder of every row -- without
* The user is eager-selected because the overview names the holder of every row -- without
* it the page fires a query per series.
*
* @return list<BookingSeries>
+12 -3
View File
@@ -20,13 +20,21 @@ final class Role
/** Derived on myep from the account's email domain: "works here". */
public const EMPLOYEE = 'ROLE_EMPLOYEE';
/**
* Frees other people's bookings and reads the codes meant for supervisors, but does not
* configure anything -- the admin area stays ROLE_ADMIN's. Nominated by the CRM and approved
* on myep like ROLE_ADMIN. ROLE_ADMIN inherits it through role_hierarchy, so a check for this
* role is a check for "supervisor or above".
*/
public const SUPERVISOR = 'ROLE_SUPERVISOR';
/** Administrative on myep -- nominated by the CRM, but only exported once approved there. */
public const ADMIN = 'ROLE_ADMIN';
/**
* The roles that entitle someone to use ephub at all. Someone who holds neither is refused
* at login rather than let in without access, so they get an explanation instead of an
* empty screen they cannot act on.
* The roles that entitle someone to use ephub at all. Someone who holds none of them is
* refused at login rather than let in without access, so they get an explanation instead of
* an empty screen they cannot act on.
*
* **Keep this in step with `required_roles` on ephub's `oauth2_client` row on myep.** myep
* gates its own authorization endpoint on the same list and shows an explanation page
@@ -39,6 +47,7 @@ final class Role
*/
public const ELIGIBLE = [
self::EMPLOYEE,
self::SUPERVISOR,
self::ADMIN,
];
+1 -1
View File
@@ -32,7 +32,7 @@ use Psr\Log\LoggerInterface;
* The acting user is NOT passed in. AuditActorProcessor takes it from the security token and
* puts it in `extra.actor`, so no call site can log the wrong one. What the context carries is
* the subject -- `holder` for a booking -- and a record whose holder is not its actor is an
* administrator having acted for somebody else.
* supervisor or administrator having acted for somebody else.
*
* Level is info throughout: an audit record is a fact, not a problem. Something that *is* a
* problem belongs in the framework log, which is what the default channel is for -- a refused
+9 -8
View File
@@ -29,14 +29,15 @@
{% endif %}
{% set interactive = not is_past and spot.actionable %}
{# An administrator may free somebody else's desk -- a colleague calls in sick and the spot
should go back into the pool. Colours are untouched: MINE still means mine, and only
clickability widens. Past days stay inert for admins too; there is no past-write path.
{# A supervisor (and so an administrator) may free somebody else's desk -- a colleague calls in
sick and the spot should go back into the pool. Colours are untouched: MINE still means
mine, and only clickability widens. Past days stay inert for them too; there is no
past-write path.
Only a card that is not already actionable: a partly booked one still opens the create
modal, because taking the free window is the more useful act there and an admin can reach
that booking from /admin/bookings anyway. #}
{% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %}
modal, because taking the free window is the more useful act there and a supervisor can
reach that booking from /manage/bookings anyway. #}
{% set manageable = not is_past and not interactive and is_granted('ROLE_SUPERVISOR') and spot.bookings is not empty %}
{# One line per booking, then the window still free if there is one -- that last line is the
reason a partly booked card is worth clicking, so it earns its place. #}
@@ -78,9 +79,9 @@
hx-target="body"
hx-swap="beforeend"
{%- elseif manageable %}
{# Not app_booking_edit: a card can hold several bookings on one day, and an admin
{# Not app_booking_edit: a card can hold several bookings on one day, and a supervisor
releases rather than reschedules. The modal lists them and frees one. #}
hx-get="{{ path('app_admin_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
hx-get="{{ path('app_manage_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- endif %}>
+7 -4
View File
@@ -62,11 +62,12 @@
{% endif %}
{#
The header is deliberately not navigation, so the dashboard is the only place an
admin screen can be reached from.
The header is deliberately not navigation, so the dashboard is the only place the
overview and the admin screens can be reached from. ROLE_SUPERVISOR includes
administrators through role_hierarchy.
#}
{% if is_granted('ROLE_ADMIN') %}
<a href="{{ path('app_admin_booking') }}"
{% if is_granted('ROLE_SUPERVISOR') %}
<a href="{{ path('app_manage_booking') }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2">
{{ ux_icon('lucide:shield-check', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
@@ -76,7 +77,9 @@
Buchungen aller Mitarbeitenden einsehen und ggf. freigeben.
</p>
</a>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
<a href="{{ path('app_admin_resource') }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2">
@@ -1,10 +1,10 @@
{#
What an administrator sees when clicking an occupied shape on the floor plan.
What a supervisor (or an administrator) sees when clicking an occupied shape on the floor plan.
Not the edit modal: a shape can hold several bookings on one day once part-day spans exist,
so "the booking" has no single referent -- and an administrator releases rather than
reschedules. Each row posts to the ordinary app_booking_release route, whose ownership check
admits an admin, so there is still one code path that removes a booking.
so "the booking" has no single referent -- and a supervisor releases rather than reschedules.
Each row posts to the ordinary app_booking_release route, whose ownership check admits
supervisors, so there is still one code path that removes a booking.
#}
{% extends 'htmx_modal.html.twig' %}
@@ -4,7 +4,7 @@
Filtering is a plain GET form -- the house rule hands navigation to the browser. The two
destructive actions open the shared confirmation modal via htmx and then navigate back here,
which is what ?return=admin says; without it a confirmed release swaps board fragments this
which is what ?return=manage says; without it a confirmed release swaps board fragments this
page does not have.
The appointment table is paginated; the series table is not, because a company has a handful of
@@ -28,7 +28,7 @@
{{ form_row(form.user) }}
</div>
<div class="flex justify-end gap-2 pt-2">
<a class="button button--small button--neutral" href="{{ path('app_admin_booking') }}">Zurücksetzen</a>
<a class="button button--small button--neutral" href="{{ path('app_manage_booking') }}">Zurücksetzen</a>
<button type="submit" class="button button--small button--primary">Filtern</button>
</div>
{{ form_end(form) }}
@@ -58,7 +58,7 @@
<td>{{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}</td>
<td class="text-right">
<button type="button" class="button button--small button--warning"
hx-get="{{ path('app_series_end', { id: entry.id, return: 'admin' }) }}"
hx-get="{{ path('app_series_end', { id: entry.id, return: 'manage' }) }}"
hx-target="body"
hx-swap="beforeend">
Wiederholung beenden
@@ -115,7 +115,7 @@
<a class="button button--small button--neutral"
href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan</a>
<button type="button" class="button button--small button--warning"
hx-get="{{ path('app_booking_release', { id: booking.id, return: 'admin' }) }}"
hx-get="{{ path('app_booking_release', { id: booking.id, return: 'manage' }) }}"
hx-target="body"
hx-swap="beforeend">
Freigeben
@@ -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']);
+8 -3
View File
@@ -9,14 +9,18 @@ use PHPUnit\Framework\TestCase;
class RoleTest extends TestCase
{
public function testBothEligibleRolesAreRecognised(): void
public function testAllEligibleRolesAreRecognised(): void
{
self::assertSame([Role::EMPLOYEE, Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN', 'ROLE_EMPLOYEE']));
self::assertSame(
[Role::EMPLOYEE, Role::SUPERVISOR, Role::ADMIN],
Role::eligibleOnly(['ROLE_ADMIN', 'ROLE_SUPERVISOR', 'ROLE_EMPLOYEE']),
);
}
public function testEitherRoleOnItsOwnIsEnough(): void
public function testEachRoleOnItsOwnIsEnough(): void
{
self::assertSame([Role::EMPLOYEE], Role::eligibleOnly(['ROLE_EMPLOYEE']));
self::assertSame([Role::SUPERVISOR], Role::eligibleOnly(['ROLE_SUPERVISOR']));
self::assertSame([Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN']));
}
@@ -35,6 +39,7 @@ class RoleTest extends TestCase
{
// myep strips these before exporting, but that is its policy and not our guarantee.
self::assertSame([], Role::eligibleOnly(['ROLE_ADMIN_PENDING']));
self::assertSame([], Role::eligibleOnly(['ROLE_SUPERVISOR_PENDING']));
}
public function testTheOrderIsStableRegardlessOfWhatMyepSends(): void