Files
TijdVoorDeTest/tests/Controller/Backoffice/SettingsControllerTest.php
T
Marijn 0ee15e3cbb feat: add GDPR data export (download data) button (#198)
* feat: add GDPR data export (download data) button

Wires up the previously disabled "Download data" button on the settings
page. Downloads a zip with a profile.xlsx (account + owned seasons), and
per owned season a folder with each quiz's xlsx (questions, results,
eliminations tabs) and a candidates.xlsx (candidates + season info tabs).
Soft-deleted rows are included and flagged so the export reflects
everything the app still holds about the user.

* feat: include question bank in GDPR data export

Adds a question-bank.xlsx per season folder with Questions (bank
questions, answers, reusable/complete flags, labels, and which quizzes
they've been used in) and Labels tabs, since BankQuestion/BankAnswer
content was previously missing from the export.

* fix: hard-delete quiz/audit-log data when deleting an account

QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so
cascading their removal through Season -> Quiz/Candidate only set
deletedAt instead of physically deleting the row. Since Candidate and
Answer are hard-deleted via orphanRemoval, this broke their foreign keys
and rolled back the entire account deletion whenever a candidate had
actually participated in a quiz. Bulk DQL deletes now purge these rows
before the cascade runs.

Also purge BankQuestion audit-log rows (ext_log_entries), which store
the editor's username/email but aren't foreign-keyed to the entity they
log, so they were never cleaned up and would otherwise keep a deleted
user's email around indefinitely.

* style: make the download data button primary (blue)

* feat: add raw answers crosstab to quiz export

Adds a "Raw answers" tab to each quiz xlsx: one row per candidate, one
column per question, with the given answer text in each cell — the raw
data behind the aggregated Results tab.

* i18n: translate new settings page string to Dutch

* refactor: sanitize filenames with Symfony's AsciiSlugger instead of a hand-rolled regex

Extracts a shared Tvdt\Helpers\FilenameSanitizer (backed by
symfony/string's AsciiSlugger) and uses it everywhere user-controlled
text (season/quiz names, account email) ends up in a zip entry path or
a downloaded filename. AsciiSlugger is allowlist-based (only A-Z/0-9
survive; everything else, including unicode and path-traversal
sequences, is folded or stripped) rather than a denylist of "unsafe"
characters, and it's an officially maintained Symfony component already
present as a transitive dependency.

Also fixes BackofficeController::exportQuiz(), a pre-existing endpoint
that built its Content-Disposition filename directly from an
unsanitized quiz name — the same class of risk the data export already
guarded against.

Naming note: sanitized names are now slugs (spaces become dashes,
unicode is ASCII-transliterated), e.g. "Krtek Weekend" -> "Krtek-Weekend".

* fix: check ZipArchive open/close results and clean up temp files on failure

Addresses CodeRabbit findings on the GDPR export:
- ZipArchive::open() and close() can both return false without
  throwing; neither was checked, so a failure silently produced an
  empty or corrupt zip, and the temp zip path was never cleaned up on
  a mid-build exception.
- writeToTempFile() leaked its tempnam()'d file if Writer\Xlsx::save()
  threw before the caller could track it for cleanup.

* fix: drop public link identifier from the candidates export sheet

The nameHash is a public quiz-access token, not something a data
export should hand out — remove it from candidates.xlsx.

* feat: add Quiz info tab covering dropouts, finalization, and disabled questions

An entity-by-entity audit of the export vs. delete flows found the
delete flow fully covered, but three Quiz/Question fields missing from
the export: dropouts, finalizedAt, and Question.enabled. Adds a new
"Quiz info" tab (first sheet) to each quiz's xlsx with this data,
without touching the shared fillQuestionsSheet() used by the existing
single-quiz template export/import feature.

Deliberately left out per user decision: the BankQuestion audit log
(would leak other owners' emails, consistent with hiding co-owner
identities elsewhere in this export) and a few low-value timestamp
fields already covered by existing Started/time-taken columns.

* feat: require a confirmed email before exporting data

Antispam measure: both the full data export (SettingsController::downloadData)
and the single-quiz export (BackofficeController::exportQuiz) now redirect
with a flash warning instead of exporting when the account's email isn't
verified yet. Adds a matching hint on the settings page next to the
download button.
2026-07-09 20:28:10 +00:00

397 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Tvdt\Controller\Backoffice\SettingsController;
use Tvdt\DataFixtures\TestFixtures;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\ResetPasswordRequest;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
#[CoversClass(SettingsController::class)]
final class SettingsControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->loginAs('test@example.org');
}
private function loginAs(string $email): void
{
$user = $this->getUserByEmail($email);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getUserByEmail(string $email): ?User
{
return $this->entityManager->getRepository(User::class)->findOneBy(['email' => $email]);
}
private function getCsrfTokenFromSettings(string $formActionContains): string
{
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
}
public function testSettingsPageLoadsAndNavContainsSettingsLink(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
self::assertSelectorExists('nav a[href="/backoffice/settings"]');
}
public function testSettingsPageRequiresAuthentication(): void
{
$this->client->restart();
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseRedirects();
}
public function testLanguageSaveRedirectsBackToSettings(): void
{
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/language');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/language', [
'_token' => $token,
'language' => 'nl',
]);
self::assertResponseRedirects('/backoffice/settings');
}
public function testChangePassword(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD,
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, 'NewPass123!'));
// User stays logged in
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
}
public function testChangePasswordWithWrongCurrentPasswordIsRejected(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => 'wrong-password',
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
}
public function testChangePasswordWithMismatchedRepeatIsRejected(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD,
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'SomethingElse!',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
}
public function testChangeEmailSendsConfirmationAndKeepsUserLoggedIn(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'new-address@example.org',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
self::assertEmailCount(1);
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
$user = $this->getUserByEmail('new-address@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified);
// User stays logged in
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
}
public function testChangeEmailToTakenAddressIsRejected(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'user1@example.org',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
self::assertEmailCount(0);
$this->entityManager->clear();
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
}
public function testResendConfirmationEmailSendsEmail(): void
{
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/resend-confirmation', [
'_token' => $token,
]);
self::assertResponseRedirects('/backoffice/settings');
self::assertEmailCount(1);
}
public function testResendConfirmationEmailForVerifiedUserSendsNothing(): void
{
// Get a valid CSRF token while still unverified, then mark the user as verified
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation');
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true;
$this->entityManager->flush();
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
$this->assertCount(0, $crawler->filter('form[action*="/backoffice/settings/resend-confirmation"]'));
$this->client->request(Request::METHOD_POST, '/backoffice/settings/resend-confirmation', [
'_token' => $token,
]);
self::assertResponseRedirects('/backoffice/settings');
self::assertEmailCount(0);
}
public function testChangeEmailToSameAddressIsAccepted(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'test@example.org',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
}
private function createResetPasswordRequest(User $user): void
{
$request = new ResetPasswordRequest(
$user,
new DateTimeImmutable('+1 hour'),
str_repeat('a', 20),
str_repeat('b', 100),
);
$this->entityManager->persist($request);
$this->entityManager->flush();
}
public function testChangePasswordInvalidatesResetPasswordRequests(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->createResetPasswordRequest($user);
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD,
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
}
public function testChangeEmailInvalidatesResetPasswordRequests(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->createResetPasswordRequest($user);
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'new-address@example.org',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$user = $this->getUserByEmail('new-address@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
}
public function testDeleteAccountWithWrongPasswordIsRejected(): void
{
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/delete');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/delete', [
'_token' => $token,
'password' => 'wrong-password',
]);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
}
public function testDeleteAccountRemovesSoleOwnerSeasonsAndKeepsSharedSeasons(): void
{
$this->loginAs('sole-owner@example.org');
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/delete');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/delete', [
'_token' => $token,
'password' => TestFixtures::PASSWORD,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('sole-owner@example.org'));
// Sole-owner season is removed, including its quiz
$this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'doomd']));
$this->assertNotInstanceOf(Quiz::class, $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Doomed Quiz']));
// Shared season survives, without the deleted owner
$anotherSeason = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'bbbbb']);
$this->assertInstanceOf(Season::class, $anotherSeason);
$ownerEmails = $anotherSeason->owners->map(static fn (User $owner): string => $owner->email)->toArray();
$this->assertNotContains('sole-owner@example.org', $ownerEmails);
$this->assertContains('user1@example.org', $ownerEmails);
// User is logged out
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseRedirects();
}
public function testDeleteAccountKeepsMultiOwnerSeasons(): void
{
$this->loginAs('user2@example.org');
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/delete');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/delete', [
'_token' => $token,
'password' => TestFixtures::PASSWORD,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('user2@example.org'));
foreach (['krtek', 'bbbbb'] as $seasonCode) {
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]);
$this->assertInstanceOf(Season::class, $season);
$ownerEmails = $season->owners->map(static fn (User $owner): string => $owner->email)->toArray();
$this->assertNotContains('user2@example.org', $ownerEmails);
$this->assertNotEmpty($ownerEmails);
}
}
public function testDownloadDataRequiresAuthentication(): void
{
$this->client->restart();
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
self::assertResponseRedirects();
}
public function testDownloadDataReturnsAZipWithATimestampedAccountFilename(): void
{
$this->markUserVerified('test@example.org');
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'application/zip');
$disposition = (string) $this->client->getResponse()->headers->get('Content-Disposition');
$this->assertMatchesRegularExpression(
'/filename=tijd-voor-de-test-data-test-example-org-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip/',
$disposition,
);
}
public function testDownloadDataRequiresVerifiedEmail(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified);
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
self::assertResponseRedirects('/backoffice/settings');
}
private function markUserVerified(string $email): void
{
$user = $this->getUserByEmail($email);
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true;
$this->entityManager->flush();
}
}