feat: expose winter placements to n8n over an api endpoint
This commit is contained in:
1 parent
4ff963d1e2
commit
0bc77b7463
17 files changed
+976
-17
No files matched your search
@@ -94,6 +94,8 @@ MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo
|
||||
MYEP_OAUTH2_SCOPES=email,id,roles,profile
|
||||
# Shared key the sibling websites send in the X-Api-Key header. Empty here: set it per stage.
|
||||
APPLICATION_FORM_API_KEY=
|
||||
# Shared key n8n reads the placements feed with, in the X-Api-Key header. Empty here: set it per stage.
|
||||
N8N_API_KEY=
|
||||
# Booking link for the Team-Infocall, substituted for {bookingUrl} in the application form
|
||||
# receipt mail. Empty here: set it per stage. While it is empty that mail is not sent at all -
|
||||
# an invitation with a dead booking link is worse than none.
|
||||
|
||||
@@ -10,5 +10,7 @@ APP_TRUSTED_HOSTS='^localhost$'
|
||||
# loaded in the test environment - so the throwaway key the tests send belongs here.
|
||||
# Keep it in sync with ApplicationFormControllerTest::KEY.
|
||||
APPLICATION_FORM_API_KEY=test-application-form-api-key
|
||||
# Keep in sync with Api\Assignment\ListControllerTest::KEY.
|
||||
N8N_API_KEY=test-n8n-api-key
|
||||
# The receipt mail refuses to render without it, and several tests send that mail.
|
||||
APPLICATION_FORM_BOOKING_URL=https://bookings.example.com/team-infocall
|
||||
@@ -13,6 +13,7 @@ security:
|
||||
memory:
|
||||
users:
|
||||
api: { roles: [ ROLE_API ] }
|
||||
n8n: { roles: [ ROLE_API_PLACEMENTS ] }
|
||||
|
||||
role_hierarchy:
|
||||
ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
|
||||
|
||||
+44
-1
@@ -40,6 +40,37 @@ parameters:
|
||||
# rejects every request instead of letting them through.
|
||||
application_form_api_key: '%env(string:APPLICATION_FORM_API_KEY)%'
|
||||
|
||||
# Shared key n8n reads the placements feed with (GET /api/einsaetze). Same rules as above.
|
||||
n8n_api_key: '%env(string:N8N_API_KEY)%'
|
||||
|
||||
# What the n8n placements feed covers: assignments in these houses (matched against the first
|
||||
# three characters of destination.hotelCode) for these job profiles - guide, course and
|
||||
# service-team work. Job profile ids are database ids, so check them against production.
|
||||
placement_export.house_codes: [ DPW, DGS, DWW, DKI, DKS ]
|
||||
placement_export.job_profile_ids:
|
||||
- 5 # Reiseleitung
|
||||
- 9 # Reiseleitung Ski
|
||||
- 10 # Reiseleitung Snowboard
|
||||
- 3 # Kursleitung Ski Anfänger:innen
|
||||
- 14 # Kursleitung Ski Fortgeschrittene
|
||||
- 18 # Kursleitung Ski Anfänger:innen + Fortgeschrittene
|
||||
- 36 # Kursleitung Ski Kinder Anfänger:innen & Kinderbetreuung
|
||||
- 37 # Kursleitung Ski Kinder Fortgeschrittene
|
||||
- 38 # Kursleitung Ski Kinder
|
||||
- 4 # Kursleitung Snowboard Anfänger:innen
|
||||
- 15 # Kursleitung Snowboard Fortgeschrittene
|
||||
- 19 # Kursleitung Snowboard Anfänger:innen + Fortgeschrittene
|
||||
- 47 # Kursleitung Snowboard Kinder/Jugendliche Anfänger:innen
|
||||
- 20 # Reise- und Kursleitung Ski Anfänger:innen
|
||||
- 21 # Reise- und Kursleitung Ski Fortgeschrittene
|
||||
- 22 # Reise- und Kursleitung Ski Anfänger:innen + Fortgeschrittene
|
||||
- 23 # Reise- und Kursleitung Snowboard Anfänger:innen
|
||||
- 24 # Reise- und Kursleitung Snowboard Fortgeschrittene
|
||||
- 25 # Reise- und Kursleitung Snowboard Anfänger:innen + Fortgeschrittene
|
||||
- 6 # Serviceteam 40h (ST40)
|
||||
- 13 # Serviceteam 25h (ST25)
|
||||
- 16 # Serviceteam 10h (ST10)
|
||||
|
||||
# Booking link the application form receipt mail invites the applicant to. A deployment
|
||||
# value rather than part of the wording, so a new booking system does not need an edit in
|
||||
# every stage's mail text.
|
||||
@@ -437,7 +468,10 @@ services:
|
||||
|
||||
App\Security\AccessToken\ApiKeyTokenHandler:
|
||||
arguments:
|
||||
$apiKey: '%application_form_api_key%'
|
||||
# user identifier in security.yaml's api_user_provider => key
|
||||
$apiKeys:
|
||||
api: '%application_form_api_key%'
|
||||
n8n: '%n8n_api_key%'
|
||||
$logger: '@monolog.logger.api'
|
||||
|
||||
App\Controller\Api\ApplicationFormController:
|
||||
@@ -445,6 +479,13 @@ services:
|
||||
$logger: '@monolog.logger.api'
|
||||
tags: [ 'controller.service_arguments' ]
|
||||
|
||||
App\Controller\Api\Assignment\ListController:
|
||||
arguments:
|
||||
$houseCodes: '%placement_export.house_codes%'
|
||||
$jobProfileIds: '%placement_export.job_profile_ids%'
|
||||
$logger: '@monolog.logger.api'
|
||||
tags: [ 'controller.service_arguments' ]
|
||||
|
||||
App\EventListener\EmailRecipientWhitelistSubscriber:
|
||||
arguments:
|
||||
$allowedRecipients: '%mail_recipient_whitelist%'
|
||||
@@ -472,6 +513,8 @@ when@dev:
|
||||
|
||||
when@test:
|
||||
parameters:
|
||||
# a job profile id ListControllerTest inserts itself - db_test has no job profiles
|
||||
placement_export.job_profile_ids: [ 900001 ]
|
||||
mailing_bus_transport: 'sync'
|
||||
# the questionnaire tests need it offered; any day before the placements they create
|
||||
questionnaire_go_live: '2026-01-01'
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Numbers the places of an assignment, for the n8n placements feed ("platz_id").
|
||||
*
|
||||
* Existing active placements are numbered 1..n per assignment in creation order; called-off ones
|
||||
* stay without a number, they hold no place. n8n has not seen a place number yet, so the initial
|
||||
* order is free to choose.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261106100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add disposition.slot and number the active placements per assignment';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE disposition ADD slot SMALLINT DEFAULT NULL');
|
||||
$this->addSql(<<<'SQL'
|
||||
UPDATE disposition
|
||||
INNER JOIN (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY assignment_id ORDER BY id) AS slot
|
||||
FROM disposition
|
||||
WHERE status <> 'called_off' AND assignment_id IS NOT NULL
|
||||
) AS numbered ON numbered.id = disposition.id
|
||||
SET disposition.slot = numbered.slot
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE disposition DROP slot');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api\Assignment;
|
||||
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Upload;
|
||||
use App\Enum\PlacementStatus;
|
||||
use App\Model\PlacementExportQueryDto;
|
||||
use App\Repository\AssignmentRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The placements feed n8n reads: one row per place of the houses' guide, course and service-team
|
||||
* assignments overlapping a date range. A row is a place, not a person - re-staffing a place
|
||||
* changes the person on the same platz_id, and an open place has person_id null. A called-off
|
||||
* assignment is left out entirely, as is a place the office struck.
|
||||
*
|
||||
* The path, the query parameters and the field names are the consumer's (German); errors are
|
||||
* English, like every response of the api firewall.
|
||||
*/
|
||||
class ListController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @param list<string> $houseCodes
|
||||
* @param list<int> $jobProfileIds
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly AssignmentRepository $assignmentRepository,
|
||||
private readonly array $houseCodes,
|
||||
private readonly array $jobProfileIds,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/api/einsaetze', name: 'app_api_assignment_list', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_API_PLACEMENTS')]
|
||||
public function index(
|
||||
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||
// Deliberately without a default: the resolver would hand that over unvalidated for a
|
||||
// request without any query string, instead of the 400 naming von and bis.
|
||||
PlacementExportQueryDto $query,
|
||||
): JsonResponse {
|
||||
$rows = [];
|
||||
|
||||
$assignments = $this->assignmentRepository->findForPlacementExport(
|
||||
$query->getFrom(),
|
||||
$query->getTo(),
|
||||
$this->houseCodes,
|
||||
$this->jobProfileIds,
|
||||
);
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
foreach ($assignment->getPlaces() as $slot => $disposition) {
|
||||
$rows[] = $this->row($assignment, $slot, $disposition);
|
||||
}
|
||||
}
|
||||
|
||||
// the range and the count, never the rows - they name people
|
||||
$this->logger->info('Placements exported', [
|
||||
'from' => $query->von,
|
||||
'to' => $query->bis,
|
||||
'assignments' => count($assignments),
|
||||
'places' => count($rows),
|
||||
]);
|
||||
|
||||
return new JsonResponse($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function row(Assignment $assignment, int $slot, ?Disposition $disposition): array
|
||||
{
|
||||
$destination = $assignment->getDestination();
|
||||
$jobProfile = $assignment->getJobProfile();
|
||||
$teamer = $disposition?->getTeamer();
|
||||
$code = (string) $destination->getCode();
|
||||
|
||||
return [
|
||||
'platz_id' => sprintf('%d-%d', $assignment->getId(), $slot),
|
||||
'einsatz_id' => $assignment->getId(),
|
||||
'person_id' => $teamer?->getId(),
|
||||
'vorname' => $teamer?->getFirstName(),
|
||||
'nachname' => $teamer?->getLastName(),
|
||||
'haus' => substr((string) $destination->getHotelCode(), 0, 3),
|
||||
'von' => $destination->getDateFrom()->format('Y-m-d'),
|
||||
'bis' => $destination->getDateTo()->format('Y-m-d'),
|
||||
'jobprofil_id' => $jobProfile->getId(),
|
||||
'jobprofil' => $jobProfile->getName(),
|
||||
'status' => PlacementStatus::fromDisposition($disposition)->value,
|
||||
'unter_26' => $this->isUnder26($disposition, $assignment),
|
||||
'vertrag_liegt_vor' => $this->hasCheckedContract($disposition),
|
||||
// Older codes carry a slash ("DPWBSP/030229"), current ones do not.
|
||||
'reise_code' => '' === $code ? null : str_replace('/', '', $code),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Under 26 on the first day of the assignment.
|
||||
*/
|
||||
private function isUnder26(?Disposition $disposition, Assignment $assignment): ?bool
|
||||
{
|
||||
if (null === $dateOfBirth = $disposition?->getTeamer()?->getDateOfBirth()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $dateOfBirth->modify('+26 years') > $assignment->getDestination()->getDateFrom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Only once the office has checked it - an uploaded contract alone does not count. Null where
|
||||
* there is no contract to have: an open place, or an assignment that skips the formalities.
|
||||
*/
|
||||
private function hasCheckedContract(?Disposition $disposition): ?bool
|
||||
{
|
||||
if (null === $disposition || true === $disposition->isSkipFormalities()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return true === $disposition->getDocumentByType(Upload::TYPE_CONTRACT)?->isConfirmed();
|
||||
}
|
||||
}
|
||||
@@ -481,6 +481,78 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The lowest place number no active placement holds - the one a new placement takes.
|
||||
*/
|
||||
public function getNextFreeSlot(): int
|
||||
{
|
||||
return $this->nextFreeSlotIn(array_flip($this->getHeldSlots()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The places the n8n feed reports, as place number => the active placement filling it, or
|
||||
* null for an open place. A place is a row there, not a person, so a called-off teamer's place
|
||||
* is open again under the same number rather than gone.
|
||||
*
|
||||
* Every held place is listed, topped up with the lowest free numbers to availableDispositions.
|
||||
* Lowering the count while a high number is still held therefore drops an open low number, not
|
||||
* the held one; a held place beyond the count is still listed, it has a person on it.
|
||||
*
|
||||
* @return array<int, Disposition|null>
|
||||
*/
|
||||
public function getPlaces(): array
|
||||
{
|
||||
$places = [];
|
||||
$unnumbered = [];
|
||||
|
||||
foreach ($this->getActiveDispositions() as $disposition) {
|
||||
if (null === $slot = $disposition->getSlot()) {
|
||||
$unnumbered[] = $disposition;
|
||||
} else {
|
||||
$places[$slot] = $disposition;
|
||||
}
|
||||
}
|
||||
|
||||
// Without a number only when created outside DisposeController; such a placement takes
|
||||
// the next free number after the numbered ones, so that nobody drops out of the feed.
|
||||
foreach ($unnumbered as $disposition) {
|
||||
$places[$this->nextFreeSlotIn($places)] = $disposition;
|
||||
}
|
||||
|
||||
while (count($places) < (int) $this->availableDispositions) {
|
||||
$places[$this->nextFreeSlotIn($places)] = null;
|
||||
}
|
||||
|
||||
ksort($places);
|
||||
|
||||
return $places;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function getHeldSlots(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->getActiveDispositions()->map(fn (Disposition $disposition) => $disposition->getSlot())->toArray(),
|
||||
fn (?int $slot) => null !== $slot,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $places
|
||||
*/
|
||||
private function nextFreeSlotIn(array $places): int
|
||||
{
|
||||
$slot = 1;
|
||||
|
||||
while (array_key_exists($slot, $places)) {
|
||||
++$slot;
|
||||
}
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
public function getConfirmedDispositions(): Collection
|
||||
{
|
||||
return $this->dispositions->filter(function (Disposition $disposition) {
|
||||
|
||||
@@ -80,6 +80,16 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
|
||||
private bool $feedbackWaived = false;
|
||||
|
||||
/**
|
||||
* The numbered place on the assignment this placement fills - the n8n feed's "platz_id" is
|
||||
* "<assignment id>-<slot>", and n8n hangs the Wechselpass off it. Taken once, here at creation,
|
||||
* and never renumbered: a replacement takes the number the called-off teamer freed, whoever
|
||||
* stays keeps theirs. A called-off placement keeps its number for the record but no longer
|
||||
* holds it (Assignment::getPlaces()).
|
||||
*/
|
||||
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||
private ?int $slot = null;
|
||||
|
||||
public function __construct(Application $application)
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
@@ -89,6 +99,7 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
? static::STATUS_CONFIRMED
|
||||
: static::STATUS_NEW;
|
||||
$this->assignment = $application->getAssignment();
|
||||
$this->slot = $this->assignment?->getNextFreeSlot();
|
||||
$this->teamer = $application->getTeamer();
|
||||
$this->pickup = $application->getPickup();
|
||||
$this->specialAgreements = $application->getRequests();
|
||||
@@ -373,4 +384,16 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSlot(): ?int
|
||||
{
|
||||
return $this->slot;
|
||||
}
|
||||
|
||||
public function setSlot(?int $slot): static
|
||||
{
|
||||
$this->slot = $slot;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
|
||||
/**
|
||||
* The status of a place in the n8n placements feed. The values are the feed's contract.
|
||||
*/
|
||||
enum PlacementStatus: string
|
||||
{
|
||||
case OPEN = 'offen';
|
||||
case REQUESTED = 'angefragt';
|
||||
case CONTRACT = 'vertrag';
|
||||
case FIXED = 'fix';
|
||||
|
||||
/**
|
||||
* A called-off placement holds no place, so it never reaches this - its place is open.
|
||||
*/
|
||||
public static function fromDisposition(?Disposition $disposition): self
|
||||
{
|
||||
return match ($disposition?->getStatus()) {
|
||||
null, Disposition::STATUS_CALLED_OFF => self::OPEN,
|
||||
Disposition::STATUS_NEW => self::REQUESTED,
|
||||
Disposition::STATUS_CHECKING_CONTRACT => self::CONTRACT,
|
||||
default => self::FIXED,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* The date range of GET /api/einsaetze, mapped from the query string. The parameter names are
|
||||
* the consumer's (n8n).
|
||||
*/
|
||||
class PlacementExportQueryDto
|
||||
{
|
||||
public function __construct(
|
||||
#[Assert\NotBlank(message: 'von must not be empty.')]
|
||||
#[Assert\Date(message: 'von must be a date as YYYY-MM-DD.')]
|
||||
public readonly ?string $von = null,
|
||||
#[Assert\NotBlank(message: 'bis must not be empty.')]
|
||||
#[Assert\Date(message: 'bis must be a date as YYYY-MM-DD.')]
|
||||
#[Assert\GreaterThanOrEqual(propertyPath: 'von', message: 'bis must not be before von.')]
|
||||
public readonly ?string $bis = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getFrom(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable($this->von);
|
||||
}
|
||||
|
||||
public function getTo(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable($this->bis);
|
||||
}
|
||||
}
|
||||
@@ -431,4 +431,53 @@ class AssignmentRepository extends ServiceEntityRepository
|
||||
->getSingleColumnResult()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The assignments the n8n placements feed reports: published, in one of the houses, for one of
|
||||
* the job profiles, and overlapping the range. Placements, their teamers and documents come
|
||||
* fetch-joined - every place row needs them. All placements are loaded, called-off ones included:
|
||||
* a fetch join on a filtered collection would leave the assignment with a partial one, and
|
||||
* Assignment::getPlaces() leaves them out anyway.
|
||||
*
|
||||
* @param list<string> $houseCodes matched against the first three characters of the hotel code
|
||||
* @param list<int> $jobProfileIds
|
||||
*
|
||||
* @return list<Assignment>
|
||||
*/
|
||||
public function findForPlacementExport(\DateTimeImmutable $from, \DateTimeImmutable $to, array $houseCodes, array $jobProfileIds): array
|
||||
{
|
||||
if ([] === $houseCodes || [] === $jobProfileIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->createQueryBuilder('assignment');
|
||||
|
||||
return $qb
|
||||
->addSelect('destination', 'jobProfile', 'disposition', 'teamer', 'document')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->innerJoin('assignment.jobProfile', 'jobProfile')
|
||||
->leftJoin('assignment.dispositions', 'disposition')
|
||||
->leftJoin('disposition.teamer', 'teamer')
|
||||
->leftJoin('disposition.documents', 'document')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->lte('destination.dateFrom', ':to'),
|
||||
$qb->expr()->gte('destination.dateTo', ':from'),
|
||||
$qb->expr()->in('SUBSTRING(destination.hotelCode, 1, 3)', ':houseCodes'),
|
||||
$qb->expr()->in('jobProfile.id', ':jobProfileIds'),
|
||||
$qb->expr()->notIn('assignment.status', ':excludedStatuses'),
|
||||
$qb->expr()->isNull('assignment.deletedAt'),
|
||||
$qb->expr()->isNull('destination.deletedAt'),
|
||||
))
|
||||
->orderBy('destination.dateFrom')
|
||||
->addOrderBy('destination.hotelCode')
|
||||
->addOrderBy('assignment.id')
|
||||
->setParameter('from', $from->format('Y-m-d'))
|
||||
->setParameter('to', $to->format('Y-m-d'))
|
||||
->setParameter('houseCodes', $houseCodes)
|
||||
->setParameter('jobProfileIds', $jobProfileIds)
|
||||
->setParameter('excludedStatuses', [Assignment::STATUS_DRAFT, Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -8,38 +8,46 @@ use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
|
||||
/**
|
||||
* Validates the shared key a sibling website posts with. There is no person to log in and no
|
||||
* account to look up: the key is the identity, and the user it resolves to is the in-memory
|
||||
* "api" user of the api_user_provider - nothing about it may reach the user table, where a row
|
||||
* would be a login to this application's UI.
|
||||
* Validates the shared key a machine caller sends. There is no person to log in and no account to
|
||||
* look up: the key is the identity, and the user it resolves to is one of the in-memory users of
|
||||
* the api_user_provider - nothing about it may reach the user table, where a row would be a login
|
||||
* to this application's UI.
|
||||
*
|
||||
* One key per caller, each resolving to its own user and roles: the websites posting application
|
||||
* forms must not read placements, and n8n reading placements must not post forms.
|
||||
*/
|
||||
class ApiKeyTokenHandler implements AccessTokenHandlerInterface
|
||||
{
|
||||
public const USER_IDENTIFIER = 'api';
|
||||
|
||||
/**
|
||||
* @param array<string, string> $apiKeys user identifier => key
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $apiKey,
|
||||
private readonly array $apiKeys,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getUserBadgeFrom(string $accessToken): UserBadge
|
||||
{
|
||||
// An unconfigured key locks the endpoint instead of opening it: hash_equals('', '')
|
||||
// is true, so without this check a deployment that forgot the secret would accept
|
||||
// an empty key from anyone.
|
||||
if ('' === $this->apiKey) {
|
||||
// An unconfigured key locks its caller out instead of opening the door: hash_equals('', '')
|
||||
// is true, so without this filter a deployment that forgot a secret would accept an empty
|
||||
// key from anyone.
|
||||
$configuredKeys = array_filter($this->apiKeys, fn (string $key) => '' !== $key);
|
||||
|
||||
if ([] === $configuredKeys) {
|
||||
$this->logger->error('API key authentication attempted while no key is configured');
|
||||
|
||||
throw new BadCredentialsException('No API key configured.');
|
||||
}
|
||||
|
||||
if (false === hash_equals($this->apiKey, $accessToken)) {
|
||||
foreach ($configuredKeys as $identifier => $key) {
|
||||
if (true === hash_equals($key, $accessToken)) {
|
||||
return new UserBadge((string) $identifier);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->warning('API key authentication failed');
|
||||
|
||||
throw new BadCredentialsException('Invalid API key.');
|
||||
}
|
||||
|
||||
return new UserBadge(self::USER_IDENTIFIER);
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,10 @@ class ApplicationVoter extends Voter
|
||||
return match ($attribute) {
|
||||
static::STATUS, static::DELETE => true,
|
||||
static::VIEW => Application::STATUS_REJECTED !== $status,
|
||||
// A called-off placement frees its place, so it does not count here: re-staffing
|
||||
// must not take raising availableDispositions, which would add a place.
|
||||
static::DISPOSE => Application::STATUS_REJECTED !== $status
|
||||
&& $assignment->getAvailableDispositions() > $assignment->getDispositions()->count(),
|
||||
&& $assignment->getAvailableDispositions() > $assignment->getActiveDispositions()->count(),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Api\Assignment;
|
||||
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Destination;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\JobProfile;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\Upload;
|
||||
use App\Security\AccessToken\ApiKeyExtractor;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The n8n placements feed against real rows in db_test: which assignments it selects, and what a
|
||||
* place row says. How places are numbered is pinned in AssignmentTest.
|
||||
*
|
||||
* Every test runs inside a transaction that is rolled back, so db_test - shared between branches -
|
||||
* keeps nothing. The job profile is inserted under the id the test environment configures for the
|
||||
* feed (services.yaml, when@test).
|
||||
*/
|
||||
class ListControllerTest extends WebTestCase
|
||||
{
|
||||
private const string KEY = 'test-n8n-api-key';
|
||||
|
||||
private const string APPLICATION_FORM_KEY = 'test-application-form-api-key';
|
||||
|
||||
private const int JOB_PROFILE_ID = 900001;
|
||||
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
private JobProfile $jobProfile;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// access_control requires https for every path, so an http request is a 301 here
|
||||
$this->client = static::createClient(server: ['HTTPS' => true]);
|
||||
$this->client->disableReboot();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->entityManager->getConnection()->beginTransaction();
|
||||
|
||||
$this->entityManager->getConnection()->insert('job_profile', [
|
||||
'id' => self::JOB_PROFILE_ID,
|
||||
'uuid' => 'a7c3f0de-0000-4000-8000-000000900001',
|
||||
'name' => 'Reiseleitung',
|
||||
'required_licenses' => '[]',
|
||||
'created_at' => '2026-01-01 00:00:00',
|
||||
]);
|
||||
$this->jobProfile = $this->entityManager->find(JobProfile::class, self::JOB_PROFILE_ID);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->entityManager->getConnection()->rollBack();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAKeylessRequestIsRejected(): void
|
||||
{
|
||||
$this->client->request('GET', '/api/einsaetze?von=2026-12-01&bis=2026-12-31');
|
||||
|
||||
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAWrongKeyIsRejected(): void
|
||||
{
|
||||
$this->get('von=2026-12-01&bis=2026-12-31', 'wrong-key');
|
||||
|
||||
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* The websites' key must not read placements - it names people.
|
||||
*/
|
||||
public function testTheApplicationFormKeyIsForbidden(): void
|
||||
{
|
||||
$this->get('von=2026-12-01&bis=2026-12-31', self::APPLICATION_FORM_KEY);
|
||||
|
||||
$this->assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testARequestWithoutARangeIsRejectedNamingBothDates(): void
|
||||
{
|
||||
$this->get('');
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEqualsCanonicalizing(['von', 'bis'], array_column($this->decode()['errors'], 'field'));
|
||||
}
|
||||
|
||||
public function testAnUnusableDateIsRejected(): void
|
||||
{
|
||||
$this->get('von=01.12.2026&bis=2026-12-31');
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertContains('von must be a date as YYYY-MM-DD.', array_column($this->decode()['errors'], 'message'));
|
||||
}
|
||||
|
||||
public function testAnInvertedRangeIsRejected(): void
|
||||
{
|
||||
$this->get('von=2026-12-31&bis=2026-12-01');
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(['bis must not be before von.'], array_column($this->decode()['errors'], 'message'));
|
||||
}
|
||||
|
||||
public function testAFilledPlaceNamesThePerson(): void
|
||||
{
|
||||
$assignment = $this->assignment();
|
||||
$teamer = $this->teamer(dateOfBirth: '2005-03-01');
|
||||
$disposition = $this->dispose($assignment, $teamer)->setStatus(Disposition::STATUS_CONFIRMED);
|
||||
$this->contract($disposition, Upload::STATUS_CHECKED);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
|
||||
$this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame([[
|
||||
'platz_id' => $assignment->getId().'-1',
|
||||
'einsatz_id' => $assignment->getId(),
|
||||
'person_id' => $teamer->getId(),
|
||||
'vorname' => 'Kim',
|
||||
'nachname' => 'Muster',
|
||||
'haus' => 'DPW',
|
||||
'von' => '2026-12-05',
|
||||
'bis' => '2026-12-12',
|
||||
'jobprofil_id' => self::JOB_PROFILE_ID,
|
||||
'jobprofil' => 'Reiseleitung',
|
||||
'status' => 'fix',
|
||||
'unter_26' => true,
|
||||
'vertrag_liegt_vor' => true,
|
||||
'reise_code' => 'DPWBSP051226',
|
||||
]], $this->decode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the person drops out, so the place stays - open, under the same number.
|
||||
*/
|
||||
public function testACalledOffTeamerLeavesAnOpenPlace(): void
|
||||
{
|
||||
$assignment = $this->assignment(places: 2);
|
||||
$calledOff = $this->dispose($assignment, $this->teamer());
|
||||
$stayer = $this->dispose($assignment, $this->teamer());
|
||||
$calledOff->setStatus(Disposition::STATUS_CALLED_OFF);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
$rows = $this->decode();
|
||||
|
||||
$this->assertSame([$assignment->getId().'-1', $assignment->getId().'-2'], array_column($rows, 'platz_id'));
|
||||
$this->assertSame([null, $stayer->getTeamer()->getId()], array_column($rows, 'person_id'));
|
||||
$this->assertSame(['offen', 'angefragt'], array_column($rows, 'status'));
|
||||
$this->assertSame([null, null], array_column($rows, 'unter_26'));
|
||||
$this->assertSame([null, false], array_column($rows, 'vertrag_liegt_vor'));
|
||||
}
|
||||
|
||||
/**
|
||||
* An uploaded contract the office has not checked yet does not count.
|
||||
*/
|
||||
public function testAnUncheckedContractIsNotThere(): void
|
||||
{
|
||||
$disposition = $this->dispose($this->assignment(), $this->teamer())->setStatus(Disposition::STATUS_CHECKING_CONTRACT);
|
||||
$this->contract($disposition, Upload::STATUS_PENDING);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
|
||||
$this->assertSame('vertrag', $this->decode()[0]['status']);
|
||||
$this->assertFalse($this->decode()[0]['vertrag_liegt_vor']);
|
||||
}
|
||||
|
||||
public function testASkipFormalitiesAssignmentHasNoContractToHave(): void
|
||||
{
|
||||
$this->dispose($this->assignment()->setSkipFormalities(true), $this->teamer());
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
|
||||
$this->assertNull($this->decode()[0]['vertrag_liegt_vor']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 26 on the first day is no longer under 26.
|
||||
*/
|
||||
public function testUnder26IsTakenOnTheFirstDay(): void
|
||||
{
|
||||
$this->dispose($this->assignment(), $this->teamer(dateOfBirth: '2000-12-05'));
|
||||
$this->dispose($this->assignment(), $this->teamer(dateOfBirth: '2000-12-06'));
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
|
||||
$this->assertSame([false, true], array_column($this->decode(), 'unter_26'));
|
||||
}
|
||||
|
||||
public function testAnAssignmentOverlappingEitherEdgeIsIncludedAndOneOutsideIsNot(): void
|
||||
{
|
||||
$startsBefore = $this->assignment(from: '2026-11-28', to: '2026-12-01');
|
||||
$endsAfter = $this->assignment(from: '2026-12-31', to: '2027-01-03');
|
||||
$this->assignment(from: '2026-11-20', to: '2026-11-30');
|
||||
$this->assignment(from: '2027-01-01', to: '2027-01-08');
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
|
||||
$this->assertSame([$startsBefore->getId(), $endsAfter->getId()], array_column($this->decode(), 'einsatz_id'));
|
||||
}
|
||||
|
||||
public function testOnlyPublishedAssignmentsOfTheHousesAndJobProfilesAreIncluded(): void
|
||||
{
|
||||
$included = $this->assignment();
|
||||
$this->assignment(hotelCode: 'SSLXYZ');
|
||||
$this->assignment()->setStatus(Assignment::STATUS_CALLED_OFF);
|
||||
$this->assignment()->setStatus(Assignment::STATUS_DRAFT);
|
||||
$this->assignment()->setDeletedAt(new \DateTimeImmutable());
|
||||
$otherProfile = (new JobProfile())->setName('Koch/Köchin');
|
||||
$this->entityManager->persist($otherProfile);
|
||||
$this->assignment()->setJobProfile($otherProfile);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
|
||||
$this->assertSame([$included->getId()], array_column($this->decode(), 'einsatz_id'));
|
||||
}
|
||||
|
||||
private function assignment(string $from = '2026-12-05', string $to = '2026-12-12', string $hotelCode = 'DPWGRU1', int $places = 1): Assignment
|
||||
{
|
||||
$destination = (new Destination())
|
||||
->setProduct('Davos - Waldschlössli')
|
||||
->setHotel('Waldschlössli')
|
||||
->setHotelCode($hotelCode)
|
||||
->setHotelBusProId(1)
|
||||
->setCode('DPWBSP/051226')
|
||||
->setDateFrom(new \DateTimeImmutable($from))
|
||||
->setDateTo(new \DateTimeImmutable($to))
|
||||
->setPickups([])
|
||||
;
|
||||
$assignment = (new Assignment())
|
||||
->setDestination($destination)
|
||||
->setJobProfile($this->jobProfile)
|
||||
->setAvailableDispositions($places)
|
||||
->setStatus(Assignment::STATUS_PUBLISHED)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($destination);
|
||||
$this->entityManager->persist($assignment);
|
||||
|
||||
return $assignment;
|
||||
}
|
||||
|
||||
private function teamer(?string $dateOfBirth = null): Teamer
|
||||
{
|
||||
$teamer = (new Teamer())
|
||||
->setFirstName('Kim')
|
||||
->setLastName('Muster')
|
||||
->setGender('d')
|
||||
->setDateOfBirth(null === $dateOfBirth ? null : new \DateTimeImmutable($dateOfBirth))
|
||||
;
|
||||
$this->entityManager->persist($teamer);
|
||||
|
||||
return $teamer;
|
||||
}
|
||||
|
||||
private function dispose(Assignment $assignment, Teamer $teamer): Disposition
|
||||
{
|
||||
$disposition = new Disposition(new Application($assignment, $teamer));
|
||||
$assignment->addDisposition($disposition);
|
||||
$this->entityManager->persist($disposition);
|
||||
|
||||
return $disposition;
|
||||
}
|
||||
|
||||
private function contract(Disposition $disposition, string $status): void
|
||||
{
|
||||
$upload = (new Upload())
|
||||
->setType(Upload::TYPE_CONTRACT)
|
||||
->setStatus($status)
|
||||
->setFilename('vertrag.pdf')
|
||||
->setOriginalFilename('vertrag.pdf')
|
||||
->setMimeType('application/pdf')
|
||||
->setSize(1024)
|
||||
;
|
||||
$disposition->addDocument($upload);
|
||||
$this->entityManager->persist($upload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the entity manager first, so the feed hydrates from the database rather than handing
|
||||
* back the collections this test built in memory.
|
||||
*/
|
||||
private function get(string $query, string $key = self::KEY): void
|
||||
{
|
||||
$this->entityManager->clear();
|
||||
|
||||
$this->client->request('GET', '/api/einsaetze?'.$query, server: [
|
||||
'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => $key,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function decode(): array
|
||||
{
|
||||
return json_decode((string) $this->client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,82 @@ class AssignmentTest extends TestCase
|
||||
yield 'pickup' => [4711, true];
|
||||
}
|
||||
|
||||
public function testTheFirstPlacementTakesPlaceOne(): void
|
||||
{
|
||||
$this->assertSame(1, $this->createDisposition(new Assignment(), new Teamer())->getSlot());
|
||||
}
|
||||
|
||||
/**
|
||||
* The n8n feed hangs the Wechselpass off the place number: a replacement takes the number the
|
||||
* called-off teamer freed, and whoever stays keeps theirs.
|
||||
*/
|
||||
public function testAReplacementTakesTheFreedPlaceAndTheStayerKeepsTheirs(): void
|
||||
{
|
||||
$assignment = (new Assignment())->setAvailableDispositions(2);
|
||||
$first = $this->createDisposition($assignment, new Teamer());
|
||||
$second = $this->createDisposition($assignment, new Teamer());
|
||||
|
||||
$first->setStatus(Disposition::STATUS_CALLED_OFF);
|
||||
$replacement = $this->createDisposition($assignment, new Teamer());
|
||||
|
||||
$this->assertSame(1, $replacement->getSlot());
|
||||
$this->assertSame(2, $second->getSlot());
|
||||
$this->assertSame([1 => $replacement, 2 => $second], $assignment->getPlaces());
|
||||
}
|
||||
|
||||
public function testACalledOffPlacementLeavesAnOpenPlace(): void
|
||||
{
|
||||
$assignment = (new Assignment())->setAvailableDispositions(2);
|
||||
$calledOff = $this->createDisposition($assignment, new Teamer());
|
||||
$stayer = $this->createDisposition($assignment, new Teamer());
|
||||
$calledOff->setStatus(Disposition::STATUS_CALLED_OFF);
|
||||
|
||||
$this->assertSame([1 => null, 2 => $stayer], $assignment->getPlaces());
|
||||
}
|
||||
|
||||
public function testUnfilledPlacesAreOpen(): void
|
||||
{
|
||||
$assignment = (new Assignment())->setAvailableDispositions(3);
|
||||
$disposition = $this->createDisposition($assignment, new Teamer());
|
||||
|
||||
$this->assertSame([1 => $disposition, 2 => null, 3 => null], $assignment->getPlaces());
|
||||
}
|
||||
|
||||
/**
|
||||
* Striking a place while a high number is still held drops an open low one, not the person.
|
||||
*/
|
||||
public function testLoweringTheCountKeepsTheHeldPlace(): void
|
||||
{
|
||||
$assignment = (new Assignment())->setAvailableDispositions(2);
|
||||
$calledOff = $this->createDisposition($assignment, new Teamer());
|
||||
$stayer = $this->createDisposition($assignment, new Teamer());
|
||||
$calledOff->setStatus(Disposition::STATUS_CALLED_OFF);
|
||||
|
||||
$assignment->setAvailableDispositions(1);
|
||||
|
||||
$this->assertSame([2 => $stayer], $assignment->getPlaces());
|
||||
}
|
||||
|
||||
public function testAPlaceHeldBeyondTheCountIsStillListed(): void
|
||||
{
|
||||
$assignment = (new Assignment())->setAvailableDispositions(2);
|
||||
$first = $this->createDisposition($assignment, new Teamer());
|
||||
$second = $this->createDisposition($assignment, new Teamer());
|
||||
|
||||
$assignment->setAvailableDispositions(1);
|
||||
|
||||
$this->assertSame([1 => $first, 2 => $second], $assignment->getPlaces());
|
||||
}
|
||||
|
||||
public function testAnUnnumberedPlacementTakesAFreeNumberAfterTheNumberedOnes(): void
|
||||
{
|
||||
$assignment = (new Assignment())->setAvailableDispositions(2);
|
||||
$unnumbered = $this->createDisposition($assignment, new Teamer())->setSlot(null);
|
||||
$numbered = $this->createDisposition($assignment, new Teamer())->setSlot(1);
|
||||
|
||||
$this->assertSame([1 => $numbered, 2 => $unnumbered], $assignment->getPlaces());
|
||||
}
|
||||
|
||||
private function createDisposition(Assignment $assignment, Teamer $teamer): Disposition
|
||||
{
|
||||
$disposition = new Disposition(new Application($assignment, $teamer));
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security\AccessToken;
|
||||
|
||||
use App\Security\AccessToken\ApiKeyTokenHandler;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||
|
||||
/**
|
||||
* Each key resolves to its own caller, and an unconfigured key must not open the door for an
|
||||
* empty one: hash_equals('', '') is true.
|
||||
*/
|
||||
class ApiKeyTokenHandlerTest extends TestCase
|
||||
{
|
||||
public function testEachKeyResolvesToItsOwnCaller(): void
|
||||
{
|
||||
$handler = $this->handler(['api' => 'forms-key', 'n8n' => 'n8n-key']);
|
||||
|
||||
$this->assertSame('api', $handler->getUserBadgeFrom('forms-key')->getUserIdentifier());
|
||||
$this->assertSame('n8n', $handler->getUserBadgeFrom('n8n-key')->getUserIdentifier());
|
||||
}
|
||||
|
||||
public function testAWrongKeyIsRejected(): void
|
||||
{
|
||||
$this->expectException(BadCredentialsException::class);
|
||||
|
||||
$this->handler(['api' => 'forms-key', 'n8n' => 'n8n-key'])->getUserBadgeFrom('other-key');
|
||||
}
|
||||
|
||||
/**
|
||||
* The extractor never hands over an empty key, but the handler must not rely on that.
|
||||
*/
|
||||
public function testAnUnconfiguredKeyDoesNotAcceptAnEmptyOne(): void
|
||||
{
|
||||
$this->expectException(BadCredentialsException::class);
|
||||
|
||||
$this->handler(['api' => 'forms-key', 'n8n' => ''])->getUserBadgeFrom('');
|
||||
}
|
||||
|
||||
public function testAnUnconfiguredKeyLeavesTheOtherWorking(): void
|
||||
{
|
||||
$this->assertSame('api', $this->handler(['api' => 'forms-key', 'n8n' => ''])->getUserBadgeFrom('forms-key')->getUserIdentifier());
|
||||
}
|
||||
|
||||
#[DataProvider('anyKey')]
|
||||
public function testNoConfiguredKeyRejectsEverything(string $key): void
|
||||
{
|
||||
$this->expectException(BadCredentialsException::class);
|
||||
|
||||
$this->handler(['api' => '', 'n8n' => ''])->getUserBadgeFrom($key);
|
||||
}
|
||||
|
||||
public static function anyKey(): iterable
|
||||
{
|
||||
yield 'empty' => [''];
|
||||
yield 'some' => ['forms-key'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $keys
|
||||
*/
|
||||
private function handler(array $keys): ApiKeyTokenHandler
|
||||
{
|
||||
return new ApiKeyTokenHandler($keys, new NullLogger());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security\Voter;
|
||||
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Teamer;
|
||||
use App\Security\Voter\ApplicationVoter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||
|
||||
/**
|
||||
* A called-off placement frees its place. Counting it would leave the office with raising
|
||||
* availableDispositions as the only way to re-staff - which adds a place to the n8n feed, where a
|
||||
* place is a row the Wechselpass hangs off.
|
||||
*/
|
||||
class ApplicationVoterTest extends TestCase
|
||||
{
|
||||
public function testACalledOffPlaceCanBeStaffedAgain(): void
|
||||
{
|
||||
$assignment = (new Assignment())->setAvailableDispositions(2);
|
||||
$this->dispose($assignment);
|
||||
$this->dispose($assignment)->setStatus(Disposition::STATUS_CALLED_OFF);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_GRANTED, $this->voteDispose($assignment));
|
||||
}
|
||||
|
||||
public function testAFullAssignmentCannotBeStaffed(): void
|
||||
{
|
||||
$assignment = (new Assignment())->setAvailableDispositions(2);
|
||||
$this->dispose($assignment);
|
||||
$this->dispose($assignment);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_DENIED, $this->voteDispose($assignment));
|
||||
}
|
||||
|
||||
private function dispose(Assignment $assignment): Disposition
|
||||
{
|
||||
$disposition = new Disposition(new Application($assignment, new Teamer()));
|
||||
$assignment->addDisposition($disposition);
|
||||
|
||||
return $disposition;
|
||||
}
|
||||
|
||||
private function voteDispose(Assignment $assignment): int
|
||||
{
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('isGranted')->willReturnCallback(fn (string $role) => 'ROLE_ADMINISTRATIVE' === $role);
|
||||
|
||||
return (new ApplicationVoter($security))->vote(
|
||||
$this->createStub(TokenInterface::class),
|
||||
new Application($assignment, new Teamer()),
|
||||
[ApplicationVoter::DISPOSE],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user