Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813aaf2cf6 | ||
|
|
5d1d7fa19e | ||
|
|
3af326251d |
No files matched your search
@@ -87,10 +87,5 @@ EP_OAUTH2_SCOPES=email,profile,roles
|
||||
###> app/features ###
|
||||
# Feature flags, read by config/packages/flagception.yaml. Off here and switched on per
|
||||
# deployment in .env.local, so a feature reaches production when its environment says so rather
|
||||
# than when it is merged.
|
||||
#
|
||||
# Door, lift and barrier codes under /access-codes and their admin table. Off until the codes
|
||||
# have actually been entered: an empty list of codes is worse than no menu entry, because it
|
||||
# reads as "there are none" to somebody standing at the door.
|
||||
FEATURE_ACCESS_CODES=false
|
||||
# than when it is merged. None at the moment.
|
||||
###< app/features ###
|
||||
@@ -8,6 +8,3 @@ APP_SECRET=44660e8fcd421376ebe0f7a7d55e179c
|
||||
|
||||
# ddev serves the application under this name; anything else is refused.
|
||||
SYMFONY_TRUSTED_HOSTS='^ephub\.ddev\.site$'
|
||||
|
||||
# On for local development, so a fresh checkout sees the feature the branch is about.
|
||||
FEATURE_ACCESS_CODES=true
|
||||
@@ -3,7 +3,3 @@ KERNEL_CLASS='App\Kernel'
|
||||
# Fixed and committed so the test suite is reproducible, and never served: .env.test is excluded
|
||||
# from the deploy rsync and production takes APP_SECRET from its own uncommitted .env.local.
|
||||
APP_SECRET='$ecretf0rt3st'
|
||||
|
||||
# The access-code suite tests the feature, not the gate in front of it, so it runs with the flag
|
||||
# on.
|
||||
FEATURE_ACCESS_CODES=true
|
||||
@@ -4,7 +4,6 @@
|
||||
# flag gets the state the feature had before it existed. Switching one on is an .env change, not
|
||||
# a release.
|
||||
flagception:
|
||||
features:
|
||||
access_codes:
|
||||
default: false
|
||||
env: FEATURE_ACCESS_CODES
|
||||
# None at the moment. A new one is an entry here, with `default: false` and an `env` variable
|
||||
# declared in .env.
|
||||
features: {}
|
||||
@@ -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 }
|
||||
@@ -7,7 +7,6 @@ namespace App\Controller\AccessCode;
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Entity\AccessCode;
|
||||
use App\Repository\AccessCodeRepository;
|
||||
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -24,7 +23,6 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
* has been replaced is gone the day its successor takes over. Both would only be one more number
|
||||
* on the screen for somebody to mistype.
|
||||
*/
|
||||
#[Feature('access_codes')]
|
||||
final class IndexController extends AbstractController
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
|
||||
@@ -11,14 +11,12 @@ use App\Htmx\HxRedirectResponse;
|
||||
use App\Security\Role;
|
||||
use App\Service\AuditLogger;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Feature('access_codes')]
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class CreateController extends AbstractController
|
||||
{
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Htmx\HxRedirectResponse;
|
||||
use App\Security\Role;
|
||||
use App\Service\AuditLogger;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -22,7 +21,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
* confirmation, which is there because a deleted code is not recoverable from the screen that
|
||||
* deleted it.
|
||||
*/
|
||||
#[Feature('access_codes')]
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class DeleteController extends AbstractController
|
||||
{
|
||||
|
||||
@@ -10,14 +10,12 @@ use App\Htmx\HxRedirectResponse;
|
||||
use App\Security\Role;
|
||||
use App\Service\AuditLogger;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Feature('access_codes')]
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class EditController extends AbstractController
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Entity\AccessCode;
|
||||
use App\Repository\AccessCodeRepository;
|
||||
use App\Security\Role;
|
||||
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -21,7 +20,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
* Which is which is a question about today rather than a stored flag, and it is answered here
|
||||
* because it is a question about the whole list: a template cannot look back over its own rows.
|
||||
*/
|
||||
#[Feature('access_codes')]
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class IndexController extends AbstractController
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+16
-12
@@ -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,
|
||||
+6
-6
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}>
|
||||
|
||||
@@ -49,24 +49,22 @@
|
||||
<p class="text-sm text-gray-500 pb-4">Alle Buchungen verwalten.</p>
|
||||
</a>
|
||||
|
||||
{# The route 404s while the feature is off, so the card has to go with it. #}
|
||||
{% if feature('access_codes') %}
|
||||
<a href="{{ path('app_access_code') }}"
|
||||
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:key-round', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
|
||||
<span class="text-lg font-bold">Zugangscodes</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 pb-4">Codes für Türen, Aufzug und Schranke.</p>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ path('app_access_code') }}"
|
||||
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:key-round', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
|
||||
<span class="text-lg font-bold">Zugangscodes</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 pb-4">Codes für Türen, Aufzug und Schranke.</p>
|
||||
</a>
|
||||
|
||||
{#
|
||||
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 +74,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.
|
||||
|
||||
@@ -26,6 +26,7 @@ class BookingControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
private EntityManagerInterface $em;
|
||||
private \DateTimeImmutable $monday;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -33,6 +34,10 @@ class BookingControllerTest extends WebTestCase
|
||||
$this->client->disableReboot();
|
||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->em->getConnection()->beginTransaction();
|
||||
// Every day these tests book on is counted from here. Fixed dates fell into the past and
|
||||
// started being refused; the next Monday is always ahead, always inside the booking
|
||||
// horizon, and keeps the weekday the series tests count on.
|
||||
$this->monday = new \DateTimeImmutable('next monday');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
@@ -45,11 +50,11 @@ class BookingControllerTest extends WebTestCase
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$this->client->request('GET', '/book/desk/day/2026-10-01', server: ['HTTP_HX-Request' => 'true']);
|
||||
$this->client->request('GET', '/book/desk/day/'.$this->day(), server: ['HTTP_HX-Request' => 'true']);
|
||||
$response = $this->client->getResponse();
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame('/book/desk?date=2026-10-01', $response->headers->get('HX-Push-Url'));
|
||||
self::assertSame('/book/desk?date='.$this->day(), $response->headers->get('HX-Push-Url'));
|
||||
self::assertSame(2, substr_count((string) $response->getContent(), 'hx-swap-oob="true"'));
|
||||
self::assertStringContainsString('id="board-calendar"', (string) $response->getContent());
|
||||
self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent());
|
||||
@@ -59,9 +64,9 @@ class BookingControllerTest extends WebTestCase
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$this->client->request('GET', '/book/desk/day/2026-10-01');
|
||||
$this->client->request('GET', '/book/desk/day/'.$this->day());
|
||||
|
||||
self::assertResponseRedirects('/book/desk?date=2026-10-01');
|
||||
self::assertResponseRedirects('/book/desk?date='.$this->day());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +78,7 @@ class BookingControllerTest extends WebTestCase
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01');
|
||||
$response = $this->submitBookingModal($desk, $this->day());
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
// The modal is gone from the response, which is what closes it: an out-of-band-only
|
||||
@@ -91,7 +96,7 @@ class BookingControllerTest extends WebTestCase
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', ['allDay' => '1', 'date' => '2020-01-01']);
|
||||
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), ['allDay' => '1', 'date' => '2020-01-01']);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('id="htmx-modal"', (string) $response->getContent());
|
||||
@@ -104,15 +109,15 @@ class BookingControllerTest extends WebTestCase
|
||||
public function testADeskAlreadyTakenIsReportedInsideTheModal(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
|
||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable($this->day())));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01');
|
||||
$response = $this->submitBookingModal($desk, $this->day());
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('ist am 01.10.2026 bereits belegt', (string) $response->getContent());
|
||||
self::assertStringContainsString(\sprintf('ist am %s bereits belegt', $this->day(0, 'd.m.Y')), (string) $response->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,12 +128,12 @@ class BookingControllerTest extends WebTestCase
|
||||
public function testASecondDeskAtTheSameTimeIsBookedAndWarnedAbout(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
|
||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day())));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D2'), '2026-10-01');
|
||||
$response = $this->submitBookingModal($this->resource('D2'), $this->day());
|
||||
$content = (string) $response->getContent();
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
@@ -139,13 +144,13 @@ class BookingControllerTest extends WebTestCase
|
||||
public function testEveryClashingDeskIsNamedInTheWarning(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
|
||||
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable('2026-10-01')));
|
||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day())));
|
||||
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable($this->day())));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D3'), '2026-10-01');
|
||||
$response = $this->submitBookingModal($this->resource('D3'), $this->day());
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertStringContainsString('bereits 2 weitere Schreibtische gebucht: D1, D2', (string) $response->getContent());
|
||||
@@ -154,12 +159,12 @@ class BookingControllerTest extends WebTestCase
|
||||
public function testTwoDesksAtDifferentTimesAreNotWarnedAbout(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 720)));
|
||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(540, 720)));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D2'), '2026-10-01', [
|
||||
$response = $this->submitBookingModal($this->resource('D2'), $this->day(), [
|
||||
'startTime' => '13:00',
|
||||
'endTime' => '17:00',
|
||||
]);
|
||||
@@ -175,12 +180,12 @@ class BookingControllerTest extends WebTestCase
|
||||
public function testADeskAndAParkingSpotOnTheSameDayAreBothAllowedWithoutAWarning(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
|
||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day())));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), '2026-10-01');
|
||||
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), $this->day());
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertStringNotContainsString('Hinweis', (string) $response->getContent());
|
||||
@@ -191,33 +196,33 @@ class BookingControllerTest extends WebTestCase
|
||||
$anna = $this->user('[email protected]');
|
||||
$other = $this->resource('D2');
|
||||
// Two of the four Mondays the series lands on, and one Tuesday it does not.
|
||||
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable('2026-10-05'), TimeRange::fromMinutes(600, 660)));
|
||||
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable('2026-10-19'), TimeRange::fromMinutes(600, 660)));
|
||||
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable('2026-10-20'), TimeRange::fromMinutes(600, 660)));
|
||||
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(600, 660)));
|
||||
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable($this->day(14)), TimeRange::fromMinutes(600, 660)));
|
||||
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable($this->day(15)), TimeRange::fromMinutes(600, 660)));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
|
||||
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), [
|
||||
'startTime' => '09:00',
|
||||
'endTime' => '12:00',
|
||||
'repeat' => 'weekly',
|
||||
'until' => '2026-10-30',
|
||||
'until' => $this->day(25),
|
||||
]);
|
||||
$content = (string) $response->getContent();
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame(1, substr_count($content, 'Hinweis:'), 'one flash for the whole series');
|
||||
self::assertStringContainsString('An 2 von 4 Terminen', $content);
|
||||
self::assertStringContainsString('(5.10., 19.10.)', $content);
|
||||
self::assertStringContainsString(\sprintf('(%s, %s)', $this->day(0, 'j.n.'), $this->day(14, 'j.n.')), $content);
|
||||
}
|
||||
|
||||
public function testMovingABookingOntoAnotherOneWarns(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$mine = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(780, 1020));
|
||||
$mine = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(780, 1020));
|
||||
$this->em->persist($mine);
|
||||
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 720)));
|
||||
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(540, 720)));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
@@ -240,8 +245,8 @@ class BookingControllerTest extends WebTestCase
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$this->client->request('POST', \sprintf('/bookings/new/%d/2026-10-01', $desk->getId()), [
|
||||
'booking' => ['date' => '2026-10-01', '_token' => 'nope'],
|
||||
$this->client->request('POST', \sprintf('/bookings/new/%d/%s', $desk->getId(), $this->day()), [
|
||||
'booking' => ['date' => $this->day(), '_token' => 'nope'],
|
||||
], server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $this->client->getResponse()->getStatusCode());
|
||||
@@ -250,7 +255,7 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
public function testReleasingAnotherPersonsBookingIsRefused(): void
|
||||
{
|
||||
$booking = new Booking($this->user('[email protected]'), $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
|
||||
$booking = new Booking($this->user('[email protected]'), $this->resource('D1'), new \DateTimeImmutable($this->day()));
|
||||
$this->em->persist($booking);
|
||||
$this->em->flush();
|
||||
|
||||
@@ -264,7 +269,7 @@ class BookingControllerTest extends WebTestCase
|
||||
public function testReleasingOwnBookingSwapsTheBoard(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
|
||||
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day()));
|
||||
$this->em->persist($booking);
|
||||
$this->em->flush();
|
||||
$id = $booking->getId();
|
||||
@@ -291,12 +296,12 @@ class BookingControllerTest extends WebTestCase
|
||||
public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
|
||||
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day()));
|
||||
$this->em->persist($booking);
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
|
||||
$crawler = $this->client->request('GET', '/book/desk?date='.$this->day());
|
||||
|
||||
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/edit"]', $booking->getId())));
|
||||
}
|
||||
@@ -312,13 +317,13 @@ class BookingControllerTest extends WebTestCase
|
||||
public function testASecondPersonCanBookTheRestOfADayFromTheDefaultBoard(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
|
||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(540, 780)));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
// 1. The shape is clickable on the all-day board.
|
||||
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
|
||||
$crawler = $this->client->request('GET', '/book/desk?date='.$this->day());
|
||||
$opener = $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId()));
|
||||
self::assertCount(1, $opener, 'a partly booked desk must still open the booking modal');
|
||||
|
||||
@@ -330,7 +335,7 @@ class BookingControllerTest extends WebTestCase
|
||||
self::assertNull($modal->filter('#booking_endTime')->attr('value'));
|
||||
|
||||
// 3. Submitting it works.
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01', [
|
||||
$response = $this->submitBookingModal($desk, $this->day(), [
|
||||
'startTime' => '13:00',
|
||||
'endTime' => '17:00',
|
||||
]);
|
||||
@@ -342,11 +347,11 @@ class BookingControllerTest extends WebTestCase
|
||||
public function testAFullyBookedDeskIsNotClickable(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
|
||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable($this->day())));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
|
||||
$crawler = $this->client->request('GET', '/book/desk?date='.$this->day());
|
||||
|
||||
self::assertCount(0, $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId())));
|
||||
}
|
||||
@@ -356,7 +361,7 @@ class BookingControllerTest extends WebTestCase
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01', [
|
||||
$response = $this->submitBookingModal($desk, $this->day(), [
|
||||
'startTime' => '09:00',
|
||||
'endTime' => '13:00',
|
||||
]);
|
||||
@@ -372,7 +377,7 @@ class BookingControllerTest extends WebTestCase
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$this->submitBookingModal($desk, '2026-10-01');
|
||||
$this->submitBookingModal($desk, $this->day());
|
||||
|
||||
$booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]);
|
||||
self::assertNotNull($booking);
|
||||
@@ -383,7 +388,7 @@ class BookingControllerTest extends WebTestCase
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', [
|
||||
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), [
|
||||
'startTime' => '13:00',
|
||||
'endTime' => '09:00',
|
||||
]);
|
||||
@@ -395,12 +400,12 @@ class BookingControllerTest extends WebTestCase
|
||||
public function testAPartDayBookingLeavesTheRestOfTheDayBookable(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
|
||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(540, 780)));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01', [
|
||||
$response = $this->submitBookingModal($desk, $this->day(), [
|
||||
'startTime' => '13:00',
|
||||
'endTime' => '17:00',
|
||||
]);
|
||||
@@ -413,13 +418,13 @@ class BookingControllerTest extends WebTestCase
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
// 2026-10-05 is a Monday, and the weekday is taken from it rather than asked for --
|
||||
// the Mondays up to the 30th are the 5th, 12th, 19th and 26th.
|
||||
$response = $this->submitBookingModal($desk, '2026-10-05', [
|
||||
// day() is a Monday, and the weekday is taken from it rather than asked for -- the
|
||||
// Mondays up to the Friday three weeks on are four.
|
||||
$response = $this->submitBookingModal($desk, $this->day(), [
|
||||
'startTime' => '09:00',
|
||||
'endTime' => '12:00',
|
||||
'repeat' => 'weekly',
|
||||
'until' => '2026-10-30',
|
||||
'until' => $this->day(25),
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
@@ -436,8 +441,8 @@ class BookingControllerTest extends WebTestCase
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
// Relative to today, unlike the rest of this file: the three-month horizon moves with the
|
||||
// clock, so a fixed end date here would start failing the day it fell outside it.
|
||||
// The three-month horizon moves with the clock, so a fixed end date here would start
|
||||
// failing the day it fell outside it.
|
||||
$start = (new \DateTimeImmutable('today'))->modify('next monday');
|
||||
$until = $start->modify('+56 days');
|
||||
|
||||
@@ -459,7 +464,7 @@ class BookingControllerTest extends WebTestCase
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
|
||||
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), [
|
||||
'allDay' => '1',
|
||||
'repeat' => 'daily',
|
||||
]);
|
||||
@@ -472,7 +477,7 @@ class BookingControllerTest extends WebTestCase
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
|
||||
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), [
|
||||
'allDay' => '1',
|
||||
'repeat' => 'daily',
|
||||
'until' => '2030-01-01',
|
||||
@@ -585,7 +590,7 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
$crawler = $this->client->request(
|
||||
'GET',
|
||||
\sprintf('/bookings/new/%d/2026-10-01?from=09:00&to=13:00', $desk->getId()),
|
||||
\sprintf('/bookings/new/%d/%s?from=09:00&to=13:00', $desk->getId(), $this->day()),
|
||||
server: ['HTTP_HX-Request' => 'true'],
|
||||
);
|
||||
|
||||
@@ -598,10 +603,10 @@ class BookingControllerTest extends WebTestCase
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$this->client->request('GET', '/book/desk/day/2026-10-01?from=09:00&to=13:00', server: ['HTTP_HX-Request' => 'true']);
|
||||
$this->client->request('GET', '/book/desk/day/'.$this->day().'?from=09:00&to=13:00', server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame('/book/desk?date=2026-10-01&from=09:00&to=13:00', $this->client->getResponse()->headers->get('HX-Push-Url'));
|
||||
self::assertSame('/book/desk?date='.$this->day().'&from=09:00&to=13:00', $this->client->getResponse()->headers->get('HX-Push-Url'));
|
||||
}
|
||||
|
||||
public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void
|
||||
@@ -610,11 +615,11 @@ class BookingControllerTest extends WebTestCase
|
||||
$this->client->loginUser($anna);
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$this->submitBookingModal($desk, '2026-10-05', [
|
||||
$this->submitBookingModal($desk, $this->day(), [
|
||||
'startTime' => '09:00',
|
||||
'endTime' => '12:00',
|
||||
'repeat' => 'weekly',
|
||||
'until' => '2026-10-26',
|
||||
'until' => $this->day(21),
|
||||
]);
|
||||
|
||||
$occurrences = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']);
|
||||
@@ -671,6 +676,14 @@ class BookingControllerTest extends WebTestCase
|
||||
return $this->client->getResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* A day counted from the next Monday, as the routes and form fields spell it.
|
||||
*/
|
||||
private function day(int $offset = 0, string $format = 'Y-m-d'): string
|
||||
{
|
||||
return $this->monday->modify(\sprintf('+%d days', $offset))->format($format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a confirmation modal and reads its CSRF token off the confirm button's hx-vals --
|
||||
* the same round trip a browser makes, rather than reaching into the token manager (which
|
||||
|
||||
+114
-27
@@ -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']);
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user