Files
TijdVoorDeTest/tests/Repository/UserRepositoryTest.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

137 lines
5.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Tvdt\Tests\Repository;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Tvdt\DataFixtures\TestFixtures;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Elimination;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Repository\UserRepository;
use function PHPUnit\Framework\assertEmpty;
#[CoversClass(UserRepository::class)]
final class UserRepositoryTest extends DatabaseTestCase
{
public function testUpgradePassword(): void
{
$passwordHasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$user = $this->getUserByEmail('user1@example.org');
$newHash = $passwordHasher->hashPassword($user, TestFixtures::PASSWORD);
$this->assertNotSame($newHash, $user->password);
$this->userRepository->upgradePassword($user, $newHash);
$this->entityManager->refresh($user);
$this->assertSame($newHash, $user->password);
}
public function testMakeAdmin(): void
{
$user = $this->getUserByEmail('test@example.org');
assertEmpty($user->roles);
$this->userRepository->makeAdmin('test@example.org');
$this->entityManager->refresh($user);
$this->assertSame(['ROLE_ADMIN'], $user->roles);
}
public function testMakeAdminInvalidEmail(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->userRepository->makeAdmin('invalid@example.org');
}
/**
* GDPR right-to-erasure: deleting the sole owner of a season must physically remove every
* row tied to it, not merely soft-delete it. QuizCandidate, GivenAnswer, and Elimination are
* all Gedmo\SoftDeleteable, so a naive $em->remove($season) cascade leaves them (or the
* transaction itself) behind. Assertions bypass the softdeleteable filter and read raw SQL,
* since a soft-deleted row would otherwise still be invisible to a filtered ORM query.
*/
public function testDeleteUserHardDeletesQuizCandidateGivenAnswerAndElimination(): void
{
$user = $this->getUserByEmail('sole-owner@example.org');
$season = $this->getSeasonByCode('doomd');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Doomed Quiz', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
$candidate = $this->getCandidateBySeasonAndName($season, 'Vera');
/** @var Question $question */
$question = $quiz->questions->first();
$rightAnswer = $question->answers->first();
$this->assertNotFalse($rightAnswer);
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
$quizCandidate = $this->quizCandidateRepository->findOneBy(['quiz' => $quiz, 'candidate' => $candidate]);
$this->assertInstanceOf(QuizCandidate::class, $quizCandidate);
$givenAnswer = new GivenAnswer($candidate, $quiz, $rightAnswer);
$this->entityManager->persist($givenAnswer);
$elimination = new Elimination($quiz);
$elimination->data = ['Vera' => Elimination::SCREEN_GREEN];
$this->entityManager->persist($elimination);
$this->entityManager->flush();
$quizCandidateId = $quizCandidate->id->toString();
$givenAnswerId = $givenAnswer->id->toString();
$eliminationId = $elimination->id->toString();
$this->userRepository->deleteUser($user);
$this->entityManager->clear();
$connection = $this->entityManager->getConnection();
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from quiz_candidate where id = ?', [$quizCandidateId]));
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from given_answer where id = ?', [$givenAnswerId]));
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from elimination where id = ?', [$eliminationId]));
}
/**
* Gedmo\Loggable writes an audit row (including the editor's username/email) to
* ext_log_entries for every change to a Versioned field. Those rows aren't linked via a
* foreign key (object_id is a plain string), so deleting the season/BankQuestion never
* cleans them up on its own — the deleted account's email would otherwise live on forever.
*/
public function testDeleteUserPurgesBankQuestionAuditLogEntries(): void
{
$user = $this->getUserByEmail('sole-owner@example.org');
$season = $this->getSeasonByCode('doomd');
$bankQuestion = new BankQuestion();
$bankQuestion->question = 'Wie is de Krtek eigenlijk?';
$bankQuestion->season = $season;
$this->entityManager->persist($bankQuestion);
$this->entityManager->flush();
$bankQuestionId = $bankQuestion->id->toString();
$connection = $this->entityManager->getConnection();
$logCountBefore = (int) $connection->fetchOne(
'select count(*) from ext_log_entries where object_class = ? and object_id = ?',
[BankQuestion::class, $bankQuestionId],
);
$this->assertGreaterThan(0, $logCountBefore);
$this->userRepository->deleteUser($user);
$this->entityManager->clear();
$logCountAfter = (int) $connection->fetchOne(
'select count(*) from ext_log_entries where object_class = ? and object_id = ?',
[BankQuestion::class, $bankQuestionId],
);
$this->assertSame(0, $logCountAfter);
}
}