From 30ad5e8488cc0ffe7e907824169752e7a102d465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 23 Sep 2026 09:03:24 +0200 Subject: [PATCH] feat: accept form submissions from the websites over an API endpoint --- .env | 2 + .env.test | 4 + application-form.http | 57 +++ config/packages/monolog.yaml | 23 +- config/packages/security.yaml | 19 + config/packages/serializer.yaml | 7 + config/services.yaml | 17 + migrations/Version20260923062331.php | 33 ++ .../Api/ApplicationFormController.php | 83 ++++ src/Entity/ApplicationFormSubmission.php | 347 +++++++++++++++++ src/EventListener/AccessDeniedListener.php | 7 + src/Model/ApplicationFormSubmissionDto.php | 242 ++++++++++++ .../ApplicationFormSubmissionRepository.php | 23 ++ src/Security/AccessToken/ApiKeyExtractor.php | 24 ++ .../AccessToken/ApiKeyTokenHandler.php | 45 +++ .../Api/ApplicationFormControllerTest.php | 365 ++++++++++++++++++ .../ApplicationFormSubmissionDtoTest.php | 56 +++ 17 files changed, 1350 insertions(+), 4 deletions(-) create mode 100644 application-form.http create mode 100644 config/packages/serializer.yaml create mode 100644 migrations/Version20260923062331.php create mode 100644 src/Controller/Api/ApplicationFormController.php create mode 100644 src/Entity/ApplicationFormSubmission.php create mode 100644 src/Model/ApplicationFormSubmissionDto.php create mode 100644 src/Repository/ApplicationFormSubmissionRepository.php create mode 100644 src/Security/AccessToken/ApiKeyExtractor.php create mode 100644 src/Security/AccessToken/ApiKeyTokenHandler.php create mode 100644 tests/Controller/Api/ApplicationFormControllerTest.php create mode 100644 tests/Model/ApplicationFormSubmissionDtoTest.php diff --git a/.env b/.env index 0ea63dc..413b046 100644 --- a/.env +++ b/.env @@ -82,6 +82,8 @@ MYEP_OAUTH2_URL_AUTHORIZE=https://my.ep-reisen.de/authorize MYEP_OAUTH2_URL_ACCESS_TOKEN=https://my.ep-reisen.de/token 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= ###> symfony/mailjet-mailer ### # Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real diff --git a/.env.test b/.env.test index e4a4168..29f1035 100644 --- a/.env.test +++ b/.env.test @@ -6,3 +6,7 @@ PANTHER_APP_ENV=panther PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots # the test client requests http://localhost/ APP_TRUSTED_HOSTS='^localhost$' +# The api firewall rejects every request while no key is configured, and .env.local is not +# 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 diff --git a/application-form.http b/application-form.http new file mode 100644 index 0000000..972b55b --- /dev/null +++ b/application-form.http @@ -0,0 +1,57 @@ +### Full submission, as the TYPO3 ApplicationApiFinisher sends it. +### The key is APPLICATION_FORM_API_KEY of the stage being called; keep the real values in +### http-client.private.env.json, which is not in the repository. +POST https://myep-team.ddev.site/api/application-forms +Content-Type: application/json +X-Api-Key: {{applicationFormApiKey}} + +{ + "form": "applicationform", + "formLabel": "Bewerbungsformular", + "submittedAt": "2026-09-22T12:30:00+02:00", + "pageUid": 1421, + "pageUrl": "https://www.ep-reisen.de/jobs/bewerbung", + "firstName": "Anna", + "lastName": "Muster", + "email": "anna@example.com", + "phone": "+49 30 1234567", + "mobile": "+49 170 1234567", + "birthDate": "01.01.1990", + "street": "Musterstraße 1", + "zipCode": "10115", + "city": "Berlin", + "details": { + "Nationalität": "deutsch", + "Führerschein": "ja", + "Ausbildung/Studium": "Tourismusmanagement", + "Bewerbung als": ["Reiseleitung", "Skilehrer"], + "Ich fahre": ["Ski", "Snowboard"], + "Wie gut fahre ich Ski": "fortgeschritten", + "Skifahrer seit": "2004", + "Einsatz datum": "ab 20.12.2026" + } +} + +### A form that maps nothing but the basics - valid, everything else is absent, not empty. +POST https://myep-team.ddev.site/api/application-forms +Content-Type: application/json +X-Api-Key: {{applicationFormApiKey}} + +{ + "form": "contactform", + "formLabel": "Kontaktformular", + "submittedAt": "2026-09-22T12:30:00+02:00", + "details": {} +} + +### Wrong key: 401, and the submission is dropped on the TYPO3 side without a retry. +POST https://myep-team.ddev.site/api/application-forms +Content-Type: application/json +X-Api-Key: definitely-not-the-key + +{ + "form": "applicationform", + "formLabel": "Bewerbungsformular", + "submittedAt": "2026-09-22T12:30:00+02:00", + "details": {} +} diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index 75b5a93..e2b2e0c 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -3,6 +3,7 @@ monolog: - deprecation - bpn - audit + - api handlers: main: @@ -11,7 +12,7 @@ monolog: handler: exceptions excluded_http_codes: [404, 405] buffer_size: 50 - channels: ["!bpn", "!audit"] + channels: ["!bpn", "!audit", "!api"] exceptions: type: rotating_file path: "%kernel.logs_dir%/framework.%kernel.environment%.log" @@ -29,6 +30,14 @@ monolog: path: '%kernel.logs_dir%/audit.%kernel.environment%.log' max_files: 5 level: info + # Form submissions posted by the sibling websites. Own file because it is machine + # traffic nobody watches between incidents, and it must stay out of the audit table. + api: + channels: [ "api" ] + type: rotating_file + path: '%kernel.logs_dir%/api.%kernel.environment%.log' + max_files: 30 + level: info database: channels: ["audit"] type: service @@ -36,7 +45,7 @@ monolog: console: type: console process_psr_3_messages: false - channels: ["!event", "!bpn", "!doctrine", "!console"] + channels: ["!event", "!bpn", "!api", "!doctrine", "!console"] when@dev: monolog: @@ -73,7 +82,7 @@ when@prod: handler: exceptions excluded_http_codes: [404, 405] buffer_size: 50 - channels: ["!bpn", "!audit"] + channels: ["!bpn", "!audit", "!api"] exceptions: type: rotating_file path: "%kernel.logs_dir%/framework.%kernel.environment%.log" @@ -91,6 +100,12 @@ when@prod: path: '%kernel.logs_dir%/audit.%kernel.environment%.log' max_files: 5 level: info + api: + channels: [ "api" ] + type: rotating_file + path: '%kernel.logs_dir%/api.%kernel.environment%.log' + max_files: 30 + level: info database: channels: ["audit"] type: service @@ -98,4 +113,4 @@ when@prod: console: type: console process_psr_3_messages: false - channels: ["!event", "!doctrine", "!bpn", "!audit"] + channels: ["!event", "!doctrine", "!bpn", "!audit", "!api"] diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 2be9228..48f07db 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -7,6 +7,12 @@ security: entity: class: App\Entity\User property: email + # The sibling websites post as this one; it exists in configuration only. An API caller + # must never be a row in the user table, where it would be a login to the UI as well. + api_user_provider: + memory: + users: + api: { roles: [ ROLE_API ] } role_hierarchy: ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ] @@ -16,6 +22,17 @@ security: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false + # Stateless and key-authenticated: a machine posts here inside a visitor's form + # submission on another site, so there is no session, no login form and no redirect. + # Must stay above "main", whose pattern is ^/. + api: + pattern: ^/api + stateless: true + provider: api_user_provider + access_token: + token_handler: App\Security\AccessToken\ApiKeyTokenHandler + token_extractors: + - App\Security\AccessToken\ApiKeyExtractor main: lazy: true provider: bpn_user_provider @@ -29,6 +46,8 @@ security: path: app_security_logout target: app_security_login access_control: + # The firewall authenticates, the controller requires ROLE_API + - { path: '^/api', roles: PUBLIC_ACCESS, requires_channel: https } - { path: '^/_uploader', roles: ROLE_USER, requires_channel: https } - { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https } diff --git a/config/packages/serializer.yaml b/config/packages/serializer.yaml new file mode 100644 index 0000000..258cd93 --- /dev/null +++ b/config/packages/serializer.yaml @@ -0,0 +1,7 @@ +# Enabled for #[MapRequestPayload] on the api controllers - nothing else in this application +# serializes. ALLOW_EXTRA_ATTRIBUTES stays at its default (true) on purpose: the TYPO3 form +# payload grows keys without a release here, and rejecting an unknown one would turn every +# later mapping change into a 400 (see .ddev/plans/application-form-endpoint.md). +framework: + serializer: + name_converter: ~ diff --git a/config/services.yaml b/config/services.yaml index adac2f1..89b4a50 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -35,6 +35,11 @@ parameters: 1461: 'SHM' 1462: 'AGR' + # Shared key the sibling websites authenticate their form submissions with. Empty here on + # purpose - the real value belongs in the environment of each stage, and an empty key + # rejects every request instead of letting them through. + application_form_api_key: '%env(string:APPLICATION_FORM_API_KEY)%' + default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' @@ -318,6 +323,18 @@ services: from: '%default_email_from%' to: '%default_email_to%' + # Its own log channel, not the audit one bound by default: the audit channel writes a row + # to the log table through App\Logging\DatabaseHandler, which records what staff did here. + App\Security\AccessToken\ApiKeyTokenHandler: + arguments: + $apiKey: '%application_form_api_key%' + $logger: '@monolog.logger.api' + + App\Controller\Api\ApplicationFormController: + arguments: + $logger: '@monolog.logger.api' + tags: [ 'controller.service_arguments' ] + App\Security\OAuth2\MyEpClient: arguments: $options: diff --git a/migrations/Version20260923062331.php b/migrations/Version20260923062331.php new file mode 100644 index 0000000..2e6c66e --- /dev/null +++ b/migrations/Version20260923062331.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE application_form_submission (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, form VARCHAR(64) NOT NULL, form_label VARCHAR(255) NOT NULL, submitted_at DATETIME NOT NULL, page_uid INT DEFAULT NULL, page_url VARCHAR(2048) DEFAULT NULL, first_name VARCHAR(255) DEFAULT NULL, last_name VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, phone VARCHAR(64) DEFAULT NULL, mobile VARCHAR(64) DEFAULT NULL, birth_date VARCHAR(64) DEFAULT NULL, street VARCHAR(255) DEFAULT NULL, zip_code VARCHAR(32) DEFAULT NULL, city VARCHAR(255) DEFAULT NULL, details JSON NOT NULL, payload JSON NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_F47CE995D17F50A6 (uuid), INDEX IDX_F47CE995B5265E72 (form, submitted_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE application_form_submission'); + } +} diff --git a/src/Controller/Api/ApplicationFormController.php b/src/Controller/Api/ApplicationFormController.php new file mode 100644 index 0000000..521765c --- /dev/null +++ b/src/Controller/Api/ApplicationFormController.php @@ -0,0 +1,83 @@ +store($dto, $request); + + $this->logger->info('Application form submission received', [ + 'uuid' => $submission->getUuid(), + 'form' => $submission->getForm(), + 'formLabel' => $submission->getFormLabel(), + 'pageUid' => $submission->getPageUid(), + 'submittedAt' => $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM), + // the labels and the answers are personal data (§7), their number is not + 'detailCount' => count($submission->getDetails()), + ]); + + return new JsonResponse( + ['status' => 'accepted', 'uuid' => $submission->getUuid()], + Response::HTTP_ACCEPTED + ); + } + + private function store(ApplicationFormSubmissionDto $dto, Request $request): ApplicationFormSubmission + { + $submission = ApplicationFormSubmission::fromDto($dto, $this->decodePayload($request)); + + $this->entityManager->persist($submission); + $this->entityManager->flush(); + + return $submission; + } + + /** + * @return array + */ + private function decodePayload(Request $request): array + { + $payload = json_decode($request->getContent(), true); + + return is_array($payload) ? $payload : []; + } +} diff --git a/src/Entity/ApplicationFormSubmission.php b/src/Entity/ApplicationFormSubmission.php new file mode 100644 index 0000000..da723ac --- /dev/null +++ b/src/Entity/ApplicationFormSubmission.php @@ -0,0 +1,347 @@ +> + */ + #[ORM\Column(type: Types::JSON)] + private array $details = []; + + /** + * The request body as received. Mapped keys this application does not know yet are ignored + * on the way in (§3.2) and the TYPO3 side widens a form's mapping without a release here, + * so without this copy those answers would be lost for good instead of backfillable. + * + * @var array + */ + #[ORM\Column(type: Types::JSON)] + private array $payload = []; + + public function __construct(string $form, string $formLabel, \DateTimeImmutable $submittedAt) + { + $this->uuid = Uuid::v4(); + $this->form = $form; + $this->formLabel = $formLabel; + $this->submittedAt = $submittedAt; + } + + /** + * The validated payload as a row. Called with a DTO that passed validation - submittedAt is + * parseable by then, which is why the constructor can insist on a date. + * + * The raw payload is passed in separately because the DTO no longer has it: the resolver + * drops every key the DTO has no property for, and those are the mapped fields the TYPO3 + * side has added and this application does not know yet (§3.2). Keeping the body is what + * makes them backfillable instead of lost. + * + * @param array $payload + */ + public static function fromDto(ApplicationFormSubmissionDto $dto, array $payload = []): self + { + $submission = new self($dto->form, $dto->formLabel, $dto->getSubmittedAt()); + + return $submission + ->setPageUid($dto->getPageUid()) + ->setPageUrl($dto->pageUrl) + ->setFirstName($dto->firstName) + ->setLastName($dto->lastName) + ->setEmail($dto->email) + ->setPhone($dto->phone) + ->setMobile($dto->mobile) + ->setBirthDate($dto->birthDate) + ->setStreet($dto->street) + ->setZipCode($dto->zipCode) + ->setCity($dto->city) + ->setDetails($dto->getNormalizedDetails()) + ->setPayload($payload) + ; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUuid(): string + { + return $this->uuid; + } + + public function getForm(): string + { + return $this->form; + } + + public function getFormLabel(): string + { + return $this->formLabel; + } + + public function getSubmittedAt(): \DateTimeImmutable + { + return $this->submittedAt; + } + + public function getPageUid(): ?int + { + return $this->pageUid; + } + + public function setPageUid(?int $pageUid): static + { + $this->pageUid = $pageUid; + + return $this; + } + + public function getPageUrl(): ?string + { + return $this->pageUrl; + } + + public function setPageUrl(?string $pageUrl): static + { + $this->pageUrl = $pageUrl; + + return $this; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(?string $firstName): static + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(?string $lastName): static + { + $this->lastName = $lastName; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): static + { + // Same normalization as Contact and Embeddable\Communication: a later match against + // existing people has to compare like with like. + $this->email = null === $email ? null : mb_strtolower(trim($email)); + + return $this; + } + + public function getPhone(): ?string + { + return $this->phone; + } + + public function setPhone(?string $phone): static + { + $this->phone = $phone; + + return $this; + } + + public function getMobile(): ?string + { + return $this->mobile; + } + + public function setMobile(?string $mobile): static + { + $this->mobile = $mobile; + + return $this; + } + + public function getBirthDate(): ?string + { + return $this->birthDate; + } + + public function setBirthDate(?string $birthDate): static + { + $this->birthDate = $birthDate; + + return $this; + } + + public function getStreet(): ?string + { + return $this->street; + } + + public function setStreet(?string $street): static + { + $this->street = $street; + + return $this; + } + + public function getZipCode(): ?string + { + return $this->zipCode; + } + + public function setZipCode(?string $zipCode): static + { + $this->zipCode = $zipCode; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(?string $city): static + { + $this->city = $city; + + return $this; + } + + /** + * @return array> + */ + public function getDetails(): array + { + return $this->details; + } + + /** + * @param array> $details + */ + public function setDetails(array $details): static + { + $this->details = $details; + + return $this; + } + + /** + * @return array + */ + public function getPayload(): array + { + return $this->payload; + } + + /** + * @param array $payload + */ + public function setPayload(array $payload): static + { + $this->payload = $payload; + + return $this; + } +} diff --git a/src/EventListener/AccessDeniedListener.php b/src/EventListener/AccessDeniedListener.php index c3fd9ad..e7118c4 100644 --- a/src/EventListener/AccessDeniedListener.php +++ b/src/EventListener/AccessDeniedListener.php @@ -36,6 +36,13 @@ class AccessDeniedListener return; } + // The api firewall is stateless and answers machines: a redirect to a login page would + // be read as success there, and the flash message below needs a session this request + // has none of. + if (str_starts_with($request->getPathInfo(), '/api/')) { + return; + } + $this->logger->warning('Access denied', [ 'uri' => $request->getRequestUri(), ]); diff --git a/src/Model/ApplicationFormSubmissionDto.php b/src/Model/ApplicationFormSubmissionDto.php new file mode 100644 index 0000000..1678797 --- /dev/null +++ b/src/Model/ApplicationFormSubmissionDto.php @@ -0,0 +1,242 @@ + $details + */ + public function __construct( + #[Assert\NotBlank(message: 'form must not be empty.')] + #[Assert\Length(max: 64, maxMessage: 'form must not exceed {{ limit }} characters.')] + public readonly ?string $form = null, + #[Assert\NotBlank(message: 'formLabel must not be empty.')] + #[Assert\Length(max: 255, maxMessage: 'formLabel must not exceed {{ limit }} characters.')] + public readonly ?string $formLabel = null, + #[Assert\NotBlank(message: 'submittedAt must not be empty.')] + public readonly ?string $submittedAt = null, + // int|string because a page id is unmistakable as "1421": rejecting the quoted form + // would cost a whole submission over a distinction nobody can see in a form editor. + // Read it back through getPageUid(). + public readonly int|string|null $pageUid = null, + #[Assert\Length(max: 2048, maxMessage: 'pageUrl must not exceed {{ limit }} characters.')] + public readonly ?string $pageUrl = null, + #[Assert\Length(max: 255)] + public readonly ?string $firstName = null, + #[Assert\Length(max: 255)] + public readonly ?string $lastName = null, + #[Assert\Email(message: 'email is not a valid email address.')] + #[Assert\Length(max: 255)] + public readonly ?string $email = null, + #[Assert\Length(max: 64)] + public readonly ?string $phone = null, + #[Assert\Length(max: 64)] + public readonly ?string $mobile = null, + #[Assert\Length(max: 64)] + public readonly ?string $birthDate = null, + #[Assert\Length(max: 255)] + public readonly ?string $street = null, + #[Assert\Length(max: 32)] + public readonly ?string $zipCode = null, + #[Assert\Length(max: 255)] + public readonly ?string $city = null, + public readonly array $details = [], + ) { + } + + /** + * The value is generated with \DateTimeInterface::ATOM on the TYPO3 side, but it is still + * outside input: it is validated here rather than trusted, and read back through + * getSubmittedAt() so the endpoint never stores a date it could not parse. + */ + #[Assert\Callback] + public function validateSubmittedAt(ExecutionContextInterface $executionContext): void + { + if (null === $this->submittedAt || '' === $this->submittedAt) { + return; + } + + if (null === $this->getSubmittedAt()) { + $executionContext + ->buildViolation('submittedAt is not a valid ISO 8601 date and time.') + ->atPath('submittedAt') + ->addViolation() + ; + } + } + + #[Assert\Callback] + public function validatePageUid(ExecutionContextInterface $executionContext): void + { + if (null === $this->pageUid || '' === $this->pageUid) { + return; + } + + if (null === $this->getPageUid()) { + $executionContext + ->buildViolation('pageUid must be a positive integer.') + ->atPath('pageUid') + ->addViolation() + ; + } + } + + /** + * details is the only half with no schema: the keys are staff-written German field labels, + * so anything a person can type is a legal key - dots, slashes, umlauts and typos included. + * Only the shape is checked, because that is all the json column and the display can carry: + * a label with content, and a scalar or a flat list as the answer. + */ + #[Assert\Callback] + public function validateDetails(ExecutionContextInterface $executionContext): void + { + foreach ($this->details as $label => $value) { + $label = (string) $label; + + if ('' === trim($label) || mb_strlen($label) > self::MAX_DETAIL_LABEL_LENGTH) { + $this->violate( + $executionContext, + sprintf('details contains an empty or overlong label ("%s").', mb_substr($label, 0, 32)) + ); + + continue; + } + + if (is_array($value) && false === array_is_list($value)) { + $this->violate($executionContext, sprintf('The value of "%s" must be a flat list.', $label)); + + continue; + } + + foreach (is_array($value) ? $value : [$value] as $single) { + if (null === $single || is_scalar($single)) { + if (is_string($single) && mb_strlen($single) > self::MAX_DETAIL_VALUE_LENGTH) { + $this->violate( + $executionContext, + sprintf('The value of "%s" exceeds %d characters.', $label, self::MAX_DETAIL_VALUE_LENGTH) + ); + } + + continue; + } + + $this->violate( + $executionContext, + sprintf('The value of "%s" must be a string, a boolean or a list of strings.', $label) + ); + } + } + } + + public function getPageUid(): ?int + { + if (null === $this->pageUid) { + return null; + } + + if (is_string($this->pageUid) && 1 !== preg_match('/^\d+$/', $this->pageUid)) { + return null; + } + + $pageUid = (int) $this->pageUid; + + return $pageUid > 0 ? $pageUid : null; + } + + public function getSubmittedAt(): ?\DateTimeImmutable + { + if (null === $this->submittedAt || '' === $this->submittedAt) { + return null; + } + + // Anchored on a date: \DateTimeImmutable happily reads "now" or "monday", and a + // submission timestamped by a relative expression is not a submission time at all. + if (1 !== preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/', $this->submittedAt)) { + return null; + } + + try { + return new \DateTimeImmutable($this->submittedAt); + } catch (\Exception) { + return null; + } + } + + /** + * The blob as it is stored: numbers arrive as strings from the form editor but nothing stops + * a mapping from sending one unquoted, and an empty answer is dropped rather than kept - + * TYPO3 strips those before sending (§3.3), so a label present in the blob always has + * content. The received order is the form's order and is never touched. + * + * @return array> + */ + public function getNormalizedDetails(): array + { + $normalized = []; + + foreach ($this->details as $label => $value) { + if (null === $value || '' === $value || [] === $value) { + continue; + } + + $normalized[(string) $label] = is_array($value) + ? array_values(array_map($this->normalizeListValue(...), $value)) + : $this->normalizeValue($value); + } + + return $normalized; + } + + private function violate(ExecutionContextInterface $executionContext, string $message): void + { + $executionContext + ->buildViolation($message) + ->atPath('details') + ->addViolation() + ; + } + + private function normalizeValue(mixed $value): string|bool + { + return is_bool($value) ? $value : (string) $value; + } + + /** + * A multi-select answer is a list of the labels the visitor picked, never a list of + * true/false: casting keeps the blob readable as what was chosen. + */ + private function normalizeListValue(mixed $value): string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + return (string) $value; + } +} diff --git a/src/Repository/ApplicationFormSubmissionRepository.php b/src/Repository/ApplicationFormSubmissionRepository.php new file mode 100644 index 0000000..b5d4dcd --- /dev/null +++ b/src/Repository/ApplicationFormSubmissionRepository.php @@ -0,0 +1,23 @@ + + * + * @method ApplicationFormSubmission|null find($id, $lockMode = null, $lockVersion = null) + * @method ApplicationFormSubmission|null findOneBy(array $criteria, array $orderBy = null) + * @method ApplicationFormSubmission[] findAll() + * @method ApplicationFormSubmission[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class ApplicationFormSubmissionRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ApplicationFormSubmission::class); + } +} diff --git a/src/Security/AccessToken/ApiKeyExtractor.php b/src/Security/AccessToken/ApiKeyExtractor.php new file mode 100644 index 0000000..26b8008 --- /dev/null +++ b/src/Security/AccessToken/ApiKeyExtractor.php @@ -0,0 +1,24 @@ +headers->get(self::HEADER, '')); + + return '' === $key ? null : $key; + } +} diff --git a/src/Security/AccessToken/ApiKeyTokenHandler.php b/src/Security/AccessToken/ApiKeyTokenHandler.php new file mode 100644 index 0000000..dd3db65 --- /dev/null +++ b/src/Security/AccessToken/ApiKeyTokenHandler.php @@ -0,0 +1,45 @@ +apiKey) { + $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)) { + $this->logger->warning('API key authentication failed'); + + throw new BadCredentialsException('Invalid API key.'); + } + + return new UserBadge(self::USER_IDENTIFIER); + } +} diff --git a/tests/Controller/Api/ApplicationFormControllerTest.php b/tests/Controller/Api/ApplicationFormControllerTest.php new file mode 100644 index 0000000..9d4a337 --- /dev/null +++ b/tests/Controller/Api/ApplicationFormControllerTest.php @@ -0,0 +1,365 @@ +client = static::createClient(server: ['HTTPS' => true]); + + $this->entityManager()->createQuery( + sprintf('DELETE FROM %s', ApplicationFormSubmission::class) + )->execute(); + } + + public function testAKeylessRequestIsRejected(): void + { + $this->post(['form' => 'applicationform'], key: null); + + $this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode()); + $this->assertSame(0, $this->countSubmissions()); + } + + public function testAWrongKeyIsRejected(): void + { + $this->post(['form' => 'applicationform'], key: 'not-the-key'); + + $this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode()); + } + + public function testAFullSubmissionIsStored(): void + { + $this->post([ + 'form' => 'applicationform', + 'formLabel' => 'Bewerbungsformular', + 'submittedAt' => '2026-09-22T12:30:00+02:00', + 'pageUid' => 1421, + 'pageUrl' => 'https://www.ep-reisen.de/jobs/bewerbung', + 'firstName' => 'Anna', + 'lastName' => 'Muster', + 'email' => 'Anna@Example.com', + 'phone' => '+49 30 1234567', + 'mobile' => '+49 170 1234567', + 'birthDate' => '01.01.1990', + 'street' => 'Musterstraße 1', + 'zipCode' => '10115', + 'city' => 'Berlin', + 'details' => [ + 'Nationalität' => 'deutsch', + 'Ausbildung/Studium' => 'Tourismusmanagement', + 'Bewerbung als' => ['Reiseleitung', 'Skilehrer'], + ], + ]); + + $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); + + $submission = $this->lastSubmission(); + + $this->assertSame('applicationform', $submission->getForm()); + $this->assertSame('Bewerbungsformular', $submission->getFormLabel()); + $this->assertSame('2026-09-22T12:30:00+02:00', $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM)); + $this->assertSame(1421, $submission->getPageUid()); + $this->assertSame('01.01.1990', $submission->getBirthDate()); + $this->assertSame('anna@example.com', $submission->getEmail(), 'the address is normalized like everywhere else'); + $this->assertSame(['Reiseleitung', 'Skilehrer'], $submission->getDetails()['Bewerbung als']); + } + + /** + * A form may map nothing but the basics, and an absent mapped key means the form has no such + * field - never an empty value that failed validation. + */ + public function testTheBasicsAloneAreEnough(): void + { + $this->post([ + 'form' => 'contactform', + 'formLabel' => 'Kontaktformular', + 'submittedAt' => '2026-09-22T12:30:00+02:00', + 'details' => [], + ]); + + $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); + + $submission = $this->lastSubmission(); + + $this->assertNull($submission->getFirstName()); + $this->assertNull($submission->getEmail()); + $this->assertSame([], $submission->getDetails()); + } + + /** + * The mapped vocabulary grows on the website side without a release here. An unknown key is + * ignored, and kept in the raw payload so it can be mapped later without losing the answers + * that arrived in the meantime. + */ + public function testAnUnknownMappedKeyIsIgnoredAndKept(): void + { + $this->post([ + 'form' => 'applicationform', + 'formLabel' => 'Bewerbungsformular', + 'submittedAt' => '2026-09-22T12:30:00+02:00', + 'nationality' => 'deutsch', + 'details' => [], + ]); + + $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); + $this->assertSame('deutsch', $this->lastSubmission()->getPayload()['nationality']); + } + + /** + * A duplicate label is disambiguated by the identifier on the TYPO3 side, so both answers + * survive as separate keys (§3.3). The order is MySQL's, not the form's - a json column + * sorts an object's keys by length - so only the set of labels is pinned here. + */ + public function testEveryLabelSurvivesIncludingADisambiguatedDuplicate(): void + { + $details = [ + 'Nationalität' => 'deutsch', + 'Führerschein' => 'ja', + 'Einsatz datum' => 'ab 20.12.2026', + 'Geburtsdatum' => '01.01.1990', + 'Geburtsdatum (text-10)' => '02.02.1992', + ]; + + $this->post([ + 'form' => 'applicationform', + 'formLabel' => 'Bewerbungsformular', + 'submittedAt' => '2026-09-22T12:30:00+02:00', + 'details' => $details, + ]); + + $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); + + $stored = $this->lastSubmission()->getDetails(); + + $this->assertEqualsCanonicalizing(array_keys($details), array_keys($stored)); + $this->assertSame('01.01.1990', $stored['Geburtsdatum']); + $this->assertSame('02.02.1992', $stored['Geburtsdatum (text-10)']); + } + + public function testALongAnswerIsStored(): void + { + $answer = str_repeat('ä', 3000); + + $this->post([ + 'form' => 'applicationform', + 'formLabel' => 'Bewerbungsformular', + 'submittedAt' => '2026-09-22T12:30:00+02:00', + 'details' => ['Warum ich' => $answer], + ]); + + $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); + $this->assertSame($answer, $this->lastSubmission()->getDetails()['Warum ich']); + } + + public function testAPayloadWithoutPageInformationIsAccepted(): void + { + $this->post([ + 'form' => 'applicationform', + 'formLabel' => 'Bewerbungsformular', + 'submittedAt' => '2026-09-22T12:30:00+02:00', + 'details' => ['Nationalität' => 'deutsch'], + ]); + + $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); + $this->assertNull($this->lastSubmission()->getPageUid()); + $this->assertNull($this->lastSubmission()->getPageUrl()); + } + + /** + * pageUid arrives as a JSON number today. A numeric string is pinned here because the + * distinction is invisible in a form editor and nobody would notice it becoming a 400. + */ + public function testANumericStringPageUidIsAccepted(): void + { + $this->post([ + 'form' => 'applicationform', + 'formLabel' => 'Bewerbungsformular', + 'submittedAt' => '2026-09-22T12:30:00+02:00', + 'pageUid' => '1421', + 'details' => [], + ]); + + $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); + $this->assertSame(1421, $this->lastSubmission()->getPageUid()); + } + + public function testABodyWithoutAJsonContentTypeIsRejected(): void + { + $this->client->request( + 'POST', + self::URL, + server: ['HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY], + content: json_encode([ + 'form' => 'applicationform', + 'formLabel' => 'Bewerbungsformular', + 'submittedAt' => '2026-09-22T12:30:00+02:00', + 'details' => [], + ], JSON_THROW_ON_ERROR) + ); + + $this->assertSame(0, $this->countSubmissions()); + // Pinned because it is what the TYPO3 side has to be told: the resolver needs the + // header to read the body as JSON at all, and answers 400 without it. + $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); + } + + public function testABrokenBodyIsRejected(): void + { + $this->client->request( + 'POST', + self::URL, + server: ['CONTENT_TYPE' => 'application/json', 'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY], + content: '{"form": "applicationform",' + ); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); + $this->assertSame(0, $this->countSubmissions()); + } + + public function testAMissingFormIsRejected(): void + { + $this->post([ + 'formLabel' => 'Bewerbungsformular', + 'submittedAt' => '2026-09-22T12:30:00+02:00', + 'details' => [], + ]); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); + $this->assertSame(0, $this->countSubmissions()); + } + + public function testAnUnparseableSubmissionTimeIsRejected(): void + { + $this->post([ + 'form' => 'applicationform', + 'formLabel' => 'Bewerbungsformular', + 'submittedAt' => 'gestern', + 'details' => [], + ]); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); + $this->assertSame(0, $this->countSubmissions()); + } + + public function testAnInvalidEmailAddressIsRejected(): void + { + $this->post([ + 'form' => 'applicationform', + 'formLabel' => 'Bewerbungsformular', + 'submittedAt' => '2026-09-22T12:30:00+02:00', + 'email' => 'not-an-address', + 'details' => [], + ]); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); + } + + public function testANestedDetailValueIsRejected(): void + { + $this->post([ + 'form' => 'applicationform', + 'formLabel' => 'Bewerbungsformular', + 'submittedAt' => '2026-09-22T12:30:00+02:00', + 'details' => ['Bewerbung als' => ['erste' => ['Reiseleitung']]], + ]); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); + $this->assertSame(0, $this->countSubmissions()); + } + + public function testTheEndpointOnlyAnswersPost(): void + { + $this->client->request('GET', self::URL, server: [ + 'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY, + ]); + + $this->assertSame(Response::HTTP_METHOD_NOT_ALLOWED, $this->client->getResponse()->getStatusCode()); + } + + /** + * §5.1 is not implemented yet: a double-clicked submit button stores two rows for now, and + * this pins that it is a known state rather than an accident. + */ + public function testTheSamePayloadTwiceIsStoredTwiceForNow(): void + { + $payload = [ + 'form' => 'applicationform', + 'formLabel' => 'Bewerbungsformular', + 'submittedAt' => '2026-09-22T12:30:00+02:00', + 'email' => 'anna@example.com', + 'details' => [], + ]; + + $this->post($payload); + $this->post($payload); + + $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); + $this->assertSame(2, $this->countSubmissions()); + } + + /** + * @param array $payload + */ + private function post(array $payload, ?string $key = self::KEY): void + { + $server = ['CONTENT_TYPE' => 'application/json']; + + if (null !== $key) { + $server['HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER))] = $key; + } + + $this->client->request('POST', self::URL, server: $server, content: json_encode($payload, JSON_THROW_ON_ERROR)); + } + + private function lastSubmission(): ApplicationFormSubmission + { + $this->entityManager()->clear(); + + /** @var ApplicationFormSubmissionRepository $repository */ + $repository = $this->entityManager()->getRepository(ApplicationFormSubmission::class); + + $submission = $repository->findOneBy([], ['id' => 'DESC']); + + $this->assertInstanceOf(ApplicationFormSubmission::class, $submission); + + return $submission; + } + + private function countSubmissions(): int + { + return (int) $this->entityManager() + ->createQuery(sprintf('SELECT COUNT(s.id) FROM %s s', ApplicationFormSubmission::class)) + ->getSingleScalarResult() + ; + } + + private function entityManager(): EntityManagerInterface + { + return static::getContainer()->get(EntityManagerInterface::class); + } +} diff --git a/tests/Model/ApplicationFormSubmissionDtoTest.php b/tests/Model/ApplicationFormSubmissionDtoTest.php new file mode 100644 index 0000000..268cc4b --- /dev/null +++ b/tests/Model/ApplicationFormSubmissionDtoTest.php @@ -0,0 +1,56 @@ +dto(['Nationalität' => 'deutsch', 'Führerschein' => '', 'Sprachen' => [], 'Sport' => null]); + + $this->assertSame(['Nationalität' => 'deutsch'], $dto->getNormalizedDetails()); + } + + public function testNumbersBecomeStringsAndBooleansStay(): void + { + $dto = $this->dto(['Skifahrer seit' => 2004, 'Datenschutz' => true]); + + $this->assertSame(['Skifahrer seit' => '2004', 'Datenschutz' => true], $dto->getNormalizedDetails()); + } + + public function testAMultiSelectStaysAFlatListOfLabels(): void + { + $dto = $this->dto(['Ich fahre' => ['Ski', 'Snowboard']]); + + $this->assertSame(['Ich fahre' => ['Ski', 'Snowboard']], $dto->getNormalizedDetails()); + } + + public function testARelativeSubmissionTimeIsNotADate(): void + { + $this->assertNull((new ApplicationFormSubmissionDto(submittedAt: 'now'))->getSubmittedAt()); + $this->assertNull((new ApplicationFormSubmissionDto(submittedAt: '2026-09-22'))->getSubmittedAt()); + $this->assertNotNull((new ApplicationFormSubmissionDto(submittedAt: '2026-09-22T12:30:00+02:00'))->getSubmittedAt()); + } + + /** + * @param array $details + */ + private function dto(array $details): ApplicationFormSubmissionDto + { + return new ApplicationFormSubmissionDto( + form: 'applicationform', + formLabel: 'Bewerbungsformular', + submittedAt: '2026-09-22T12:30:00+02:00', + details: $details, + ); + } +}