feat: accept form submissions from the websites over an API endpoint
This commit is contained in:
1 parent
f48e53fdd0
commit
30ad5e8488
17 files changed
+1350
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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": "[email protected]",
|
||||
"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": {}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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: ~
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Form submissions posted by the sibling websites (TYPO3 ApplicationApiFinisher).
|
||||
*
|
||||
* Generated with doctrine:migrations:diff and reduced to these two statements: the diff also
|
||||
* swept in the long-standing dev drift in the datetime and json column declarations, plus
|
||||
* tables that only exist on another branch.
|
||||
*/
|
||||
final class Version20260923062331 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add application_form_submission for form submissions posted by the websites';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Model\ApplicationFormSubmissionDto;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Receives a form submitted on one of the public websites, posted by the TYPO3
|
||||
* ApplicationApiFinisher. Authenticated by the API key of the stateless "api" firewall.
|
||||
*
|
||||
* The visitor waits for this response inside their own form submission and TYPO3 gives up after
|
||||
* a few seconds without retrying, so this stores the row and answers - notification mail and any
|
||||
* matching against known people belong in a worker, not here
|
||||
* (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §6).
|
||||
*/
|
||||
class ApplicationFormController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed mapping or validation answers 400 rather than the resolver's default 422: the
|
||||
* contract names 400 for an unusable payload, and the TYPO3 side logs the response body.
|
||||
* Errors are English - the only caller is a machine.
|
||||
*/
|
||||
#[Route('/api/application-forms', name: 'app_api_application_form_submit', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_API')]
|
||||
public function submit(
|
||||
Request $request,
|
||||
#[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||
ApplicationFormSubmissionDto $dto,
|
||||
): JsonResponse {
|
||||
$submission = $this->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<string, mixed>
|
||||
*/
|
||||
private function decodePayload(Request $request): array
|
||||
{
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
|
||||
return is_array($payload) ? $payload : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Model\ApplicationFormSubmissionDto;
|
||||
use App\Repository\ApplicationFormSubmissionRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* A form submitted on one of the public websites and posted here by TYPO3 - job applications
|
||||
* first, other "collect it and let a human read it" forms later. Unrelated to
|
||||
* App\Entity\Application, which is a teamer applying for an assignment.
|
||||
*
|
||||
* Deliberately neither Blameable nor SoftDeletable: nothing in this application creates these
|
||||
* rows, and the retention rule for job applications asks for real deletion, so this is the one
|
||||
* entity here that gets an actual DELETE. A purge has to cover "details" and "payload" as
|
||||
* well - a json column is exactly what a deletion job forgets.
|
||||
*
|
||||
* The typed columns are the mapped core agreed with the TYPO3 side and all nullable: a form is
|
||||
* configured without a field as often as with it. "details" is everything else the visitor
|
||||
* filled in, keyed by the German field label in the order the form asked - display only, never
|
||||
* a schema, never queried on (see .ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.3).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ApplicationFormSubmissionRepository::class)]
|
||||
// What a list and a dedupe check ask for: the submissions of one form, newest first.
|
||||
#[ORM\Index(name: 'IDX_F47CE995B5265E72', columns: ['form', 'submitted_at'])]
|
||||
class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
{
|
||||
use TimestampableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(length: 64)]
|
||||
private string $form;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $formLabel;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $submittedAt;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $pageUid = null;
|
||||
|
||||
#[ORM\Column(length: 2048, nullable: true)]
|
||||
private ?string $pageUrl = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
private ?string $mobile = null;
|
||||
|
||||
/**
|
||||
* A string, not a date: the forms use a text field with a regex rather than a date picker,
|
||||
* so this is whatever the visitor typed - usually dd.mm.yyyy. Parse it for display, store
|
||||
* what arrived.
|
||||
*/
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
private ?string $birthDate = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 32, nullable: true)]
|
||||
private ?string $zipCode = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $city = null;
|
||||
|
||||
/**
|
||||
* Everything the visitor filled in beyond the mapped core, keyed by the German field label.
|
||||
* Display only: the labels are staff-written and change on a reword, so nothing may query
|
||||
* this, build a column from it or migrate old rows to a new spelling (§3.3).
|
||||
*
|
||||
* Note the labels come back in MySQL's order, not the form's - a json column normalises an
|
||||
* object by sorting its keys by length. Render it in whatever order suits the reviewer; the
|
||||
* form's own order is still in $payload, which is read as one blob and never sorted by us.
|
||||
*
|
||||
* @var array<string, string|bool|list<string>>
|
||||
*/
|
||||
#[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<string, mixed>
|
||||
*/
|
||||
#[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<string, mixed> $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<string, string|bool|list<string>>
|
||||
*/
|
||||
public function getDetails(): array
|
||||
{
|
||||
return $this->details;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|list<string>> $details
|
||||
*/
|
||||
public function setDetails(array $details): static
|
||||
{
|
||||
$this->details = $details;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getPayload(): array
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function setPayload(array $payload): static
|
||||
{
|
||||
$this->payload = $payload;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* One submitted TYPO3 form, mapped from the request body by #[MapRequestPayload].
|
||||
*
|
||||
* Two rules from the contract (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.2, §3.3) shape this
|
||||
* class and must survive any rewrite:
|
||||
*
|
||||
* - **Unknown keys are ignored, never rejected.** The mapped vocabulary grows on the TYPO3
|
||||
* side without a release here, so nothing may switch the serializer's ALLOW_EXTRA_ATTRIBUTES
|
||||
* off: it would turn every later mapping change into a 400, silent because the finisher only
|
||||
* logs it.
|
||||
* - **Everything but the four basics is optional.** A form is configured without a field as
|
||||
* often as with it, so an absent key means "not part of this form", never "empty" - hence the
|
||||
* defaults, which also let the serializer construct the object from a payload of four keys.
|
||||
*
|
||||
* The lengths are the storage limits of ApplicationFormSubmission, not an opinion about what a
|
||||
* visitor may type: they exist so an oversized value is a 400 the TYPO3 side logs, and not a
|
||||
* database error after the response has been promised.
|
||||
*/
|
||||
class ApplicationFormSubmissionDto
|
||||
{
|
||||
public const MAX_DETAIL_LABEL_LENGTH = 255;
|
||||
|
||||
public const MAX_DETAIL_VALUE_LENGTH = 65535;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, string|bool|list<string>>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ApplicationFormSubmission>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security\AccessToken;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Security\Http\AccessToken\AccessTokenExtractorInterface;
|
||||
|
||||
/**
|
||||
* Reads the API key of the stateless "api" firewall from its own header. The built-in header
|
||||
* extractor expects "Authorization: Bearer", which would claim a shared secret is an OAuth
|
||||
* access token - this application is the client of a real OAuth provider (MYEP_OAUTH2_*), and
|
||||
* the two must not look alike in a request.
|
||||
*/
|
||||
class ApiKeyExtractor implements AccessTokenExtractorInterface
|
||||
{
|
||||
public const HEADER = 'X-Api-Key';
|
||||
|
||||
public function extractAccessToken(Request $request): ?string
|
||||
{
|
||||
$key = trim((string) $request->headers->get(self::HEADER, ''));
|
||||
|
||||
return '' === $key ? null : $key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security\AccessToken;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||
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.
|
||||
*/
|
||||
class ApiKeyTokenHandler implements AccessTokenHandlerInterface
|
||||
{
|
||||
public const USER_IDENTIFIER = 'api';
|
||||
|
||||
public function __construct(
|
||||
private readonly string $apiKey,
|
||||
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) {
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Api;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Repository\ApplicationFormSubmissionRepository;
|
||||
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 cases here are the ones the TYPO3 side actually produces
|
||||
* (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §8): a payload of nothing but the basics, labels
|
||||
* with slashes and umlauts, a long textarea answer, a duplicate label, and a mapped key this
|
||||
* application does not know - which must not be a 400, or a later mapping change on the
|
||||
* website silently stops every submission of that form.
|
||||
*/
|
||||
class ApplicationFormControllerTest extends WebTestCase
|
||||
{
|
||||
private const URL = '/api/application-forms';
|
||||
|
||||
private const KEY = 'test-application-form-api-key';
|
||||
|
||||
private KernelBrowser $client;
|
||||
|
||||
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->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' => '[email protected]',
|
||||
'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('[email protected]', $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' => '[email protected]',
|
||||
'details' => [],
|
||||
];
|
||||
|
||||
$this->post($payload);
|
||||
$this->post($payload);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(2, $this->countSubmissions());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Model;
|
||||
|
||||
use App\Model\ApplicationFormSubmissionDto;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The normalization the json column relies on: no empty answers, numbers as strings, booleans
|
||||
* kept, and a multi-select as a flat list of what was picked.
|
||||
*/
|
||||
class ApplicationFormSubmissionDtoTest extends TestCase
|
||||
{
|
||||
public function testEmptyAnswersAreDropped(): void
|
||||
{
|
||||
$dto = $this->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<string, mixed> $details
|
||||
*/
|
||||
private function dto(array $details): ApplicationFormSubmissionDto
|
||||
{
|
||||
return new ApplicationFormSubmissionDto(
|
||||
form: 'applicationform',
|
||||
formLabel: 'Bewerbungsformular',
|
||||
submittedAt: '2026-09-22T12:30:00+02:00',
|
||||
details: $details,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user