Wednesday, 17 March 2021

Symfony and TDD: adding server-side POST request validation

G'day:

In the previous article ("Vue.js and TDD: adding client-side form field validation") I added the small amount of client-side validation this data entry form I'm working on needs (full mise-en-scène can be caught-up-with via a bunch of articles tagged with "VueJs/Symfony/Docker/TDD series"). In this article I'm returning to the back-end to implement validation for the data the front-end will be passing to a web service endpoint.

Even to start with I taught myself a TDD lesson in "let's not get ahead of ourselves". I leapt in and started writing test case names (note the plural), and ran the incomplete tests to show myself how clever I was:

Unit tests of WorkshopsController::doPost
  It returns a 400 status if fullName is greater than 100 characters
  It returns a 400 status if phoneNumber is greater than 50 characters
  It returns a 400 status if emailAddress is greater than 320 characters
  It returns a 400 status if password is greater than 255 characters

And then I thought "OK I better add in the controller method for that, so I can get cracking with the coding". And then I thought "erm… actually a route would be nice. And… that's not even the right controller I'm mentioning there". And I'm not sure I should be mentioning a controller in a test case anyhow. The POST requests aren't for /workshops/ (hence WorkshopsController): POSTing to /workshops/ would be for adding a new Workshop. What I'm wanting to do here is to add a new WorkshopRegistration. So: /workshop-registrations/ and WorkshopRegistrationsController. But now that I'm thinking about it, I think mentioning WorkshopRegistrationsController is a bit implementation-specific and not relevant to the user of the test case. What they're doing is POSTing to /workshop-registrations/, and getting the results they want. How that's implemented is irrelevant. So - lesson learned about getting ahead of myself - the first case is "it will accept POST requests on /workshop-registrations/, and return a 201 CREATED if successful". Let's just write that test first. Let's only write one case, one test, and one implementation at a time in this exercise, eh?

I had not committed any of that code above, so I just reverted it.


It will accept POST requests on /workshop-registrations/, and return a 201 CREATED if successful

This is straight forward, and we already have a very similar test in tests\functional\Controller\WorkshopsControllerTest for the /workshops/ route. Here's the one for the new requirement:

namespace adamCameron\fullStackExercise\tests\functional\Controller;

use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\HttpFoundation\Response;

/** @testdox Functional tests of /workshop-registrations/ endpoint */
class WorkshopRegistrationsControllerTest extends WebTestCase
{
    private KernelBrowser $client;

    public static function setUpBeforeClass(): void
    {
        $dotenv = new Dotenv();
        $dotenv->load(dirname(__DIR__, 3) . "/.env.test");
    }

    protected function setUp(): void
    {
        $this->client = static::createClient(['debug' => false]);
    }

    /**
     * @testdox it needs to return a 201-CREATED status for successful POST requests
     * @covers \adamCameron\fullStackExercise\Controller\WorkshopRegistrationsController
     */
    public function testDoPostReturns201(): void
    {
        $this->client->request('POST', '/workshop-registrations/');

        $this->assertEquals(Response::HTTP_CREATED, $this->client->getResponse()->getStatusCode());
    }
}

(I've been slightly naughty and already introduced a slight refactoring there to have the setUpBeforeClass and setUp methods, based on the earlier work demonstrating we'll need those. Also cos I copy and pasted the whole WorkshopsControllerTestclass and just changed some bits). Running that fails for obvious reasons (the code it is calling not existing yet being the main one), so we're good to implement that code now.

namespace adamCameron\fullStackExercise\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

class WorkshopRegistrationsController extends AbstractController
{

    public function doPost() : JsonResponse
    {
        return new JsonResponse(null, Response::HTTP_CREATED);
    }
}

And the test passes. Now I can start thinking about other cases that involve behaviour of hitting that end point.

It must receive a JSON object with fullName, phoneNumber, workshopsToAttend, emailAddress and password properties, otherwise will return a 400-BAD-REQUEST status

Before we start worrying about length-checks and other validation rules; let's just ease into things: we receive an object with the correct schema.

One interesting thing that popped up when I started doing the test case for this is that the test case description is over the length that PHPCS is happy with, and I was getting a warning:

root@12a5e50e1652:/usr/share/fullstackExercise# composer phpcs
> vendor/bin/phpcs --standard=phpcs.xml.dist

FILE: tests/functional/Controller/WorkshopRegistrationsControllerTest.php
-------------------------------------------------------------------------
FOUND 0 ERRORS AND 1 WARNING AFFECTING 1 LINE
-------------------------------------------------------------------------
48 | WARNING | Line exceeds 120 characters; contains 177 characters
-------------------------------------------------------------------------

I tried to work out how to split that description over multiple lines, but seems there's a "shortfall" in PHPUnit that prohibits dealing with this: `@testdox` annotations should be allowed to be multiline. #4511. This was closed without being considered with a rather subpar comment of "I do not think so.", which is disappointing. Obviously me being me: I put my oar in there. However as it happens once I implemented the test logic, the length came back within 120chars, so PHPCS was happy. Here's the test code:

/**
 * @testdox It must receive a JSON object with a $property property, otherwise will return a 400-BAD-REQUEST status
 * @dataProvider provideSchemaPropertyCheckTestCases
 * @covers \adamCameron\fullStackExercise\Controller\WorkshopRegistrationsController
 */
public function testRequiredPropertiesArePresentInBody($property)
{
    $testBody = $this->getValidObjectForTestRequest();
    unset($testBody->$property);
    $this->client->request(
        'POST',
        '/workshop-registrations/',
        [],
        [],
        [],
        json_encode($testBody)
    );

    $this->assertEquals(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
}

public function provideSchemaPropertyCheckTestCases()
{
    return [
        ['property' => 'fullName'],
        ['property' => 'phoneNumber'],
        ['property' => 'workshopsToAttend'],
        ['property' => 'emailAddress'],
        ['property' => 'password']
    ];
}

private function getValidObjectForTestRequest()
{
    return (object) [
        'fullName' => static::UNTESTED_VALUE,
        'phoneNumber' => static::UNTESTED_VALUE,
        'workshopsToAttend' => static::UNTESTED_VALUE,
        'emailAddress' => static::UNTESTED_VALUE,
        'password' => static::UNTESTED_VALUE
    ];
}

(note that - offscreen - I have also updated the previous test to use that getValidObjectForTestRequest method).

The implementation for this will be more complicated than that for the first test.

First: an aside. As I am not a lunatic, I am not going to hand-crank my own validation. I am going to use Symfony Validation. Validation can be tricky, and it's critical so it's not a good idea to hand-crank it. Use an established library. My axiom for stuff like this is to consider "what I am in the business of?" In this context I am in the business of registering workshops. I am not in the business of writing validation libraries. Therefore: I should not be engaging in writing validation libraries; I'll leave that to someone who is in the business of writing validation libraries.

Anyway… here's the implementation code:

class WorkshopRegistrationsController extends AbstractController
{

    private WorkshopRegistrationValidationService $validator;

    public function __construct(WorkshopRegistrationValidationService $validator)
    {
        $this->validator = $validator;
    }

    public function doPost(Request $request) : JsonResponse
    {
        try {
            $this->validator->validate($request);
        } catch (WorkshopRegistrationValidationException $e) {
            return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
        }
        return new JsonResponse(null, Response::HTTP_CREATED);
    }
}

Note we've added a dependency of a WorkshopRegistrationValidationService there, which we use to validate the incoming request. If it throws a WorkshopRegistrationValidationException then we had some issues, so we return a 400.

WorkshopRegistrationValidationException is just a placeholder at the moment:

class WorkshopRegistrationValidationException extends DomainException
{
}

WorkshopRegistrationValidationService is more interesting:

class WorkshopRegistrationValidationService
{

    private ValidatorInterface $validator;

    public function __construct(ValidatorInterface $validator)
    {
        $this->validator = $validator;
    }

    public function validate(Request $request): void
    {
        $content = $request->getContent();
        $values = json_decode($content, true);
        $constraints = $this->getConstraints();

        $violations = $this->validator->validate($values, $constraints);

        if (count($violations) > 0) {
            throw new WorkshopRegistrationValidationException();
        }
    }

    private function getConstraints()
    {
        return new Assert\Collection([
            'fullName' => new Assert\NotBlank(),
            'phoneNumber' => new Assert\NotBlank(),
            'workshopsToAttend' => new Assert\NotBlank(),
            'emailAddress' => new Assert\NotBlank(),
            'password' => new Assert\NotBlank()
        ]);
    }
}

Notes:

  • It takes a ValidatorInterface, which will be a Symfony Validator implementation.
  • Its validate method passes the JSON from the request body to Symfony's validate method, along with the constrainst to validate against.
  • If that call returns any constraint violations, we throw that exception we saw above.
  • And it contains the constraints we are using for validation.

It's not doing much yet, and doing nothing with the values except validate them, but that's all we need to do for the current test case.

To get the Symfony Validator object into the WorkshopRegistrationValidationService we need to use a factory again, like we did in an earlier article for the WorkshopCollection (this is in services.yaml)

adamCameron\fullStackExercise\Factory\WorkshopCollectionFactory: ~
adamCameron\fullStackExercise\Model\WorkshopCollection:
    factory: ['@adamCameron\fullStackExercise\Factory\WorkshopCollectionFactory', 'getWorkshopCollection']

adamCameron\fullStackExercise\Factory\ValidatorFactory: ~
Symfony\Component\Validator\Validator\ValidatorInterface:
    factory: ['@adamCameron\fullStackExercise\Factory\ValidatorFactory', 'getValidator']

The factory itself is simple. We just need it to make the dependency injection work with that method chaining we need to do. Basically some complexity Symfony has created for itself. And by association: me :-(

class ValidatorFactory
{

    public function getValidator() : ValidatorInterface
    {
        return Validation::createValidatorBuilder()->getValidator();
    }
}

And that sorts out the tests.

It returns details of the validation failures in the body of the 400 response

Test:

/**
 * @testdox It returns details of the validation failures in the body of the 400 response
 * @covers ::doPost
 */
public function testValidationFailsAreReturned()
{
    $testBody = $this->getValidObjectForTestRequest();
    unset($testBody->fullName);
    unset($testBody->password);

    $this->client->request('POST', '/workshop-registrations/', [], [], [], json_encode($testBody));
    $response = $this->client->getResponse();
    $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
    $content = json_decode($response->getContent());

    $this->assertObjectHasAttribute('errors', $content);
    $this->assertEquals(
        (object) [
            'errors' => [
                (object) ['field' => '[fullName]', 'message' => 'This field is missing.'],
                (object) ['field' => '[password]', 'message' => 'This field is missing.']
            ]
        ],
        $content
    );
}

This is possibly a wee bit fragile because I'm relying on the order Symfony is validating things, but this'll works now, so it's fine. I have to admit my first pass of the test of this was using completely wrong values in there - by design - so I could see what the right values in the failure was once I did my implementation, and then fixed the test to match the values. IE I had no idea that Symfony returned This field is missing. as the constraint violation message, so my assertion initially checked for 'NOT_THE_CORRECT_MESSAGE'.

Implementation:

// WorkshopRegistrationsController
//...
return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
return new JsonResponse(['errors' => $e->getErrors()], Response::HTTP_BAD_REQUEST);
// WorkshopRegistrationValidationService
// ...
if (count($violations) > 0) {
    throw new WorkshopRegistrationValidationException();
    $errors = array_map(function ($violation) {
        return [
            'field' => $violation->getPropertyPath(),
            'message' => $violation->getMessage()
        ];
    }, $violations->getIterator()->getArrayCopy());
    throw new WorkshopRegistrationValidationException($errors);
}
class WorkshopRegistrationValidationException extends DomainException
{
    public function __construct($errors)
    {
        $this->errors = $errors;
    }

    public function getErrors()
    {
        return $this->errors;
    }
}

That's all pretty self-explanatory.

It [checks a bunch of Other Stuff]

I started to do this on a case by case basis, but everything else is just configuration of that getConstraints method of WorkshopRegistrationValidationService that I mentioned a bit further up. Instead of doing a heading-line for each, I'll list the rest of the test cases I've added, and show you the code for the whole lot. All the tests are very very similar, and don't need much specific focus. So I added these cases:

  • It should not accept a fullName with length greater than 100 characters
  • It should not accept a phoneNumber with length greater than 50 characters
  • It should not accept a emailAddress with length greater than 320 characters
  • It should not accept an emailAddress with length less than 3 characters
  • It cannot·have·fewer·than·8·characters for password
  • It can·have·exactly·8·characters for password
  • It can·have·more·than·8·characters for password
  • It must·have·at·least·one·lowercase·letter for password
  • It must·have·at·least·one·uppercase·letter for password
  • It must·have·at·least·one·digit for password
  • It must·have·at·least·one·non-alphanumeric·character for password
  • It cannot have embedded XSS risk for fullName
  • It cannot have embedded XSS risk for phoneNumber
  • It cannot have embedded XSS risk for emailAddress

For the sake of clarity, I'll dump out the final constrains first up here, as it'll make it easier to see what the tests are doing. But I did make sure to write the tests before adding any new constraints in!

private function getConstraints()
{
    return new Assert\Collection([
        'fields' => [
            'fullName' => [
                new Assert\Length(['min' => 1, 'max' => 100]),
                new ContainsXssRiskConstraint()
            ],
            'phoneNumber' => [
                new Assert\Length(['min' => 1, 'max' => 50]),
                new ContainsXssRiskConstraint()
            ],
            'workshopsToAttend' => [
                new Assert\NotBlank(),
                new Assert\All([
                    new Assert\Type('int')
                ])
            ],
            'emailAddress' => [
                new Assert\Length(['min' => 3, 'max' => 320]),
                new ContainsXssRiskConstraint()
            ],
            'password' => [
                new Assert\Regex([
                    'pattern' => '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*\W)(?:.){8,}$/',
                    'message' => 'Failed complexity validation'
                ])
            ]
        ],
        'allowMissingFields' => false,
        'allowExtraFields' => true
    ]);
}

That's all pretty straight forward I think? I've had to create a custom constraint in there, the code for which is just this lot:

class ContainsXssRiskConstraint extends Constraint
{
    public $message = 'The string contains illegal content';
}

class ContainsXssRiskConstraintValidator extends ConstraintValidator
{
    public function validate($value, Constraint $constraint)
    {
        if (htmlspecialchars($value) !== $value) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ string }}', $value)
                ->addViolation();
        }
    }
}

(I've stripped some Symfony boilerplate out of the second one, but the if statement is the bit that does the validation). For the constraint array and these two classes, I just read along with the Symfony Validation docs. They've made it very easy.

Now for the test code.

/**
 * @testdox It should not accept a $property with length greater than $length characters
 * @dataProvider provideStringLengthCheckTestCases
 */
public function testStringLengthValidation($property, $length)
{
    $testBody = $this->getValidObjectForTestRequest();
    $testBody->$property = str_repeat('X', $length);

    $this->client->request('POST', '/workshop-registrations/', [], [], [], json_encode($testBody));
    $response = $this->client->getResponse();
    $this->assertEquals(Response::HTTP_CREATED, $response->getStatusCode());

    $testBody = $this->getValidObjectForTestRequest();
    $testBody->$property = str_repeat('X', $length + 1);

    $this->client->request('POST', '/workshop-registrations/', [], [], [], json_encode($testBody));
    $response = $this->client->getResponse();
    $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
}

public function provideStringLengthCheckTestCases()
{
    return [
        ['property' => 'fullName', 'length' => 100],
        ['property' => 'phoneNumber', 'length' => 50],
        ['property' => 'emailAddress', 'length' => 320]
    ];
}

Here I'm cheating slightly and testing two things: that the maximum length is OK, and that over that is not OK. You might note that I am not length-checking the password here. We're not gonna be putting the clear-text password into the DB, we'll be hashing it and the hashes are always a set length, so we don't need to care how long the original password is.

It's the same deal with this next one which is just testing that the email address minimum is 3:

/**
 * @testdox It should not accept an emailAddress with length less than 3 characters
 */
public function testEmailMinLengthValidation()
{
    $testBody = $this->getValidObjectForTestRequest();
    $testBody->emailAddress = 'a@b';

    $this->client->request('POST', '/workshop-registrations/', [], [], [], json_encode($testBody));
    $response = $this->client->getResponse();
    $this->assertEquals(Response::HTTP_CREATED, $response->getStatusCode());

    $testBody = $this->getValidObjectForTestRequest();
    $testBody->emailAddress = 'a@';

    $this->client->request('POST', '/workshop-registrations/', [], [], [], json_encode($testBody));
    $response = $this->client->getResponse();
    $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
}

Next I'm testing the password complexity (the cases are lifted straight from the client-side validation code, in frontend/test/unit/workshopRegistration.spec.js):

/**
 * @testdox It $_dataName for password
 * @dataProvider providePasswordTestCases
 */
public function testPasswordValidation($testValue, $expectedErrors)
{
    $testBody = $this->getValidObjectForTestRequest();
    $testBody->password = $testValue;

    $this->client->request('POST', '/workshop-registrations/', [], [], [], json_encode($testBody));
    $response = $this->client->getResponse();

    if (!count($expectedErrors)) {
        $this->assertEquals(Response::HTTP_CREATED, $response->getStatusCode());
        return;
    }
    $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
    $content = json_decode($response->getContent());

    $this->assertEquals($expectedErrors, $content->errors);
}

public function providePasswordTestCases()
{
    $expectedErrors = [(object) ['field' => '[password]', 'message' => 'Failed complexity validation']];
    return [
        'cannot have fewer than 8 characters' => [
            'password' => 'Aa1!567',
            'expectedErrors' => $expectedErrors
        ],
        'can have exactly 8 characters' => [
            'password' => 'Aa1!5678',
            'expectedErrors' => []
        ],
        'can have more than 8 characters' => [
            'password' => 'Aa1!56789',
            'expectedErrors' => []
        ],
        'must have at least one lowercase letter' => [
            'password' => 'A_1!56789',
            'expectedErrors' => $expectedErrors
        ],
        'must have at least one uppercase letter' => [
            'password' => '_a1!56789',
            'expectedErrors' => $expectedErrors
        ],
        'must have at least one digit' => [
            'password' => 'Aa_!efghi',
            'expectedErrors' => $expectedErrors
        ],
        'must have at least one non-alphanumeric character' => [
            'password' => 'Aa1x56789',
            'expectedErrors' => $expectedErrors
        ]
    ];
}

Notice how I'm using the test case label in the @testDox annotation value. That's quite cool.

Finally, and partially as an excuse to create that custom constraint, I just check if the text fields have any nasty surprises in them:

/**
 * @testdox It cannot have embedded XSS risk for $property
 * @testWith    ["fullName"]
 *              ["phoneNumber"]
 *              ["emailAddress"]
 */
public function testXssInTextFieldValidation($property)
{
    $testBody = $this->getValidObjectForTestRequest();
    $testBody->$property = '<script>hijackTheirSession()</script>';

    $this->client->request('POST', '/workshop-registrations/', [], [], [], json_encode($testBody));
    $response = $this->client->getResponse();
    $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());

    $content = json_decode($response->getContent());

    $this->assertEquals(
        [
            (object) ['field' => "[$property]", 'message' => 'The string contains illegal content']
        ],
        $content->errors
    );
}

(Note I'm using a different tactic with the data provider here too. And this is still supported by the @testDox).

The other change I needed to make was to update the wee method I'm using to provide that initial getValidObjectForTestRequest at the beginning of all the tests:

class WorkshopRegistrationsControllerTest extends WebTestCase
{
    // ...

    private const UNTESTED_VALUE = 'UNTESTED_VALUE';
    private const UNTESTED_INT_VALUE = -1;
    private const UNTESTED_PASSWORD_VALUE = 'aA1!1234';

    // ...

    private function getValidObjectForTestRequest()
    {
        return (object) [
            'fullName' => static::UNTESTED_VALUE,
            'phoneNumber' => static::UNTESTED_VALUE,
            'workshopsToAttend' => [static::UNTESTED_INT_VALUE],
            'emailAddress' => static::UNTESTED_VALUE,
            'password' => static::UNTESTED_PASSWORD_VALUE
        ];
    }
}

This is just so all the earlier tests keep passing as I toughen up the validation constraints.

Now I can run all the tests, and they all pass:

> vendor/bin/phpunit --testdox 'tests/functional/Controller/WorkshopRegistrationsControllerTest.php'
PHPUnit 9.5.2 by Sebastian Bergmann and contributors.

Functional tests of /workshop-registrations/ endpoint
it needs to return a 201-CREATED status for successful POST requests
It must receive a JSON object with a fullName property, otherwise will return a 400-BAD-REQUEST status
It must receive a JSON object with a phoneNumber property, otherwise will return a 400-BAD-REQUEST status
It must receive a JSON object with a workshopsToAttend property, otherwise will return a 400-BAD-REQUEST status
It must receive a JSON object with a emailAddress property, otherwise will return a 400-BAD-REQUEST status
It must receive a JSON object with a password property, otherwise will return a 400-BAD-REQUEST status
It returns details of the validation failures in the body of the 400 response
It should not accept a fullName with length greater than 100 characters
It should not accept a phoneNumber with length greater than 50 characters
It should not accept a emailAddress with length greater than 320 characters
It should not accept an emailAddress with length less than 3 characters
It cannot·have·fewer·than·8·characters for password
It can·have·exactly·8·characters for password
It can·have·more·than·8·characters for password
It must·have·at·least·one·lowercase·letter for password
It must·have·at·least·one·uppercase·letter for password
It must·have·at·least·one·digit for password
It must·have·at·least·one·non-alphanumeric·character for password
It cannot have embedded XSS risk for fullName
It cannot have embedded XSS risk for phoneNumber
It cannot have embedded XSS risk for emailAddress

Time: 00:00.419, Memory: 22.00 MB

OK (21 tests, 35 assertions)

Generating code coverage report in HTML format ... done [00:00.929]
root@12a5e50e1652:/usr/share/fullstackExercise#

Excellent, eh? Um. Kinda.

It doesn't frickin test everything it should

That's not another test case, it's a statement of fact. The next thing I did was to triumphantly go to my browser's REST-testing app (Talend API Tester - Free Edition),and hit the endpoint with a POST request. To start with I didn't send a body, cos I was gonna build it up key by key. But… erm… I got this:

Huh?

I'll spare you the details of my hour of googling and frustration. It turns out that if you give the Symfony validator a NULL, then it skips the validation. By design. Unfrickinbelievable.

I needed another few test cases then:

/**
 * @testdox it will not accept $_dataName for the body
 * @covers ::doPost
 * @dataProvider provideUnexpectedBodyTestCases
 */
public function testDoPostUnexpectedBodyErrorsOut($body): void
{
    $this->client->request('POST', '/workshop-registrations/', [], [], [], $body);
    $response = $this->client->getResponse();

    $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());

    $content = json_decode($response->getContent());
    $this->assertObjectHasAttribute('errors', $content);
    $this->assertEquals(
        (object) [
            'errors' => [
                (object) ['field' => '[fullName]', 'message' => 'This field is missing.'],
                (object) ['field' => '[phoneNumber]', 'message' => 'This field is missing.'],
                (object) ['field' => '[workshopsToAttend]', 'message' => 'This field is missing.'],
                (object) ['field' => '[emailAddress]', 'message' => 'This field is missing.'],
                (object) ['field' => '[password]', 'message' => 'This field is missing.']
            ]
        ],
        $content
    );
}

public function provideUnexpectedBodyTestCases()
{
    return [
        'nothing' => ['body' => json_encode(null)],
        'empty object' => ['body' => json_encode((object)[])],
        'not JSON' => ['body' => 'NOT_JSON']
    ];
}

(I noticed that third case whilst testing too: json_decode silently fails if the input you give it isn't JSON, and it just returns null).

The fix was simple (in backend/src/Service/WorkshopRegistrationValidationService.php):

public function validate(Request $request)
{
    $content = $request->getContent();
    $values = json_decode($content, true) ?? []; // Symfony won't validate null (apparently by "design")
    $constraints = $this->getConstraints();
    $violations = $this->validator->validate($values, $constraints);

    if (count($violations) > 0) {
        $errors = array_map(function ($violation) {
            return [
                'field' => $violation->getPropertyPath(),
                'message' => $violation->getMessage()
            ];
        }, $violations->getIterator()->getArrayCopy());
        throw new WorkshopRegistrationValidationException($errors);
    }
}

It must allow underscore as the one non-alphanumeric character for password

I also notice this one when testing by hand. I had a bug in my regex pattern for the password in that it did not consider underscore to be punctuation. I appended a quick test case:

'must have at least one non-alphanumeric character' => [
    'password' => 'Aa1x56789',
    'expectedErrors' => $expectedErrors
],
'must allow underscore as the one non-alphanumeric character' => [
    'password' => 'Aa1_56789',
    'expectedErrors' => []
]

The fix was dead easy:

'password' => [
    new Assert\Regex([
        'pattern' => '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*\W)(?:.){8,}$/',
        'pattern' => '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?:.){8,}$/',
        'message' => 'Failed complexity validation'
    ])
]

Stupid me went "yeah \W (non-word character) covers all punctuation". But underscore is a word character. For some reason. I knew this, I just didn't think. Anyway, fixed now. It does just go to show that with the best will in the world, one will never think of all test cases for a given situation. I'm glad I found these two though. Also glad I had all the other test cases in place so when I fixed these two I could be confident they didn't break any of the other case.

OK so barring other bugs I didn't spot, I'm done with the Symfony side of the validation now. I need to go back to the front end and deal with situations when the server comes back with a 400. Currently it assumes success. And then after I do that work, I can start thinking about pushing stuff into the database. Hopefully. So I guess two more articles to go…

Righto.

--
Adam