Files
TijdVoorDeTest/tests/Service/DataExportServiceTest.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

287 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace Tvdt\Tests\Service;
use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PHPUnit\Framework\Attributes\CoversClass;
use Tvdt\Entity\Answer;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\User;
use Tvdt\Service\DataExportService;
use Tvdt\Tests\Repository\DatabaseTestCase;
use function Safe\file_put_contents;
use function Safe\tempnam;
use function Safe\unlink;
#[CoversClass(DataExportService::class)]
final class DataExportServiceTest extends DatabaseTestCase
{
private DataExportService $subject;
/** @var list<string> */
private array $tempFiles = [];
protected function setUp(): void
{
parent::setUp();
$this->subject = self::getContainer()->get(DataExportService::class);
}
protected function tearDown(): void
{
foreach ($this->tempFiles as $path) {
if (file_exists($path)) {
unlink($path);
}
}
parent::tearDown();
}
public function testExportForUserWithNoSeasonsContainsOnlyProfile(): void
{
$zip = $this->openZip($this->getUserByEmail('test@example.org'));
$this->assertSame(1, $zip->numFiles);
$this->assertNotFalse($zip->locateName('profile.xlsx'));
$zip->close();
}
public function testExportForUserIncludesOwnedSeasonsQuizzesAndCandidates(): void
{
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$names = $this->entryNames($zip);
$this->assertContains('profile.xlsx', $names);
$this->assertContains('krtek-Krtek-Weekend/Quiz-1.xlsx', $names);
$this->assertContains('krtek-Krtek-Weekend/Quiz-2.xlsx', $names);
$this->assertContains('krtek-Krtek-Weekend/candidates.xlsx', $names);
$this->assertContains('krtek-Krtek-Weekend/question-bank.xlsx', $names);
$this->assertContains('bbbbb-Another-Season/candidates.xlsx', $names);
$this->assertContains('bbbbb-Another-Season/question-bank.xlsx', $names);
// Another Season has no quizzes, so no quiz xlsx should be present for it.
foreach ($names as $name) {
$this->assertStringStartsNotWith('bbbbb-Another-Season/Quiz', $name);
}
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$this->assertSame(['Quiz info', 'Questions', 'Raw answers', 'Results', 'Eliminations'], $this->sheetNames($quizContent));
$candidatesContent = $zip->getFromName('krtek-Krtek-Weekend/candidates.xlsx');
$this->assertIsString($candidatesContent);
$this->assertSame(['Candidates', 'Season info'], $this->sheetNames($candidatesContent));
$questionBankContent = $zip->getFromName('krtek-Krtek-Weekend/question-bank.xlsx');
$this->assertIsString($questionBankContent);
$this->assertSame(['Questions', 'Labels'], $this->sheetNames($questionBankContent));
$zip->close();
}
public function testQuestionBankSheetIncludesBankQuestionsAndUsage(): void
{
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$questionBankContent = $zip->getFromName('krtek-Krtek-Weekend/question-bank.xlsx');
$this->assertIsString($questionBankContent);
$zip->close();
$rows = $this->loadSheet($questionBankContent, 'Questions')->toArray();
$header = $rows[0];
$dataRows = \array_slice($rows, 1);
$questionIndex = array_search('Question', $header, true);
$reusableIndex = array_search('Reusable', $header, true);
$labelsIndex = array_search('Labels', $header, true);
$usedInQuizzesIndex = array_search('Used in quizzes', $header, true);
$reusableRow = current(array_filter($dataRows, static fn (array $row): bool => 'Wie is de Krtek?' === $row[$questionIndex]));
$this->assertIsArray($reusableRow);
$this->assertSame('Yes', $reusableRow[$reusableIndex]);
$this->assertSame('Finale', $reusableRow[$labelsIndex]);
$usedRow = current(array_filter($dataRows, static fn (array $row): bool => 'Waar sliep de Krtek?' === $row[$questionIndex]));
$this->assertIsArray($usedRow);
$this->assertSame('Quiz 2', $usedRow[$usedInQuizzesIndex]);
$labelRows = $this->loadSheet($questionBankContent, 'Labels')->toArray();
$labelNames = array_column(\array_slice($labelRows, 1), 0);
$this->assertContains('Locatie', $labelNames);
$this->assertContains('Finale', $labelNames);
}
public function testProfileSheetDoesNotContainPasswordHash(): void
{
$user = $this->getUserByEmail('user2@example.org');
$zip = $this->openZip($user);
$profileContent = $zip->getFromName('profile.xlsx');
$this->assertIsString($profileContent);
$zip->close();
$rows = $this->loadSheet($profileContent, 'Account')->toArray();
$flattened = implode(' ', array_merge(...$rows));
$this->assertStringNotContainsString($user->password, $flattened);
}
public function testResultsSheetIncludesSoftDeletedQuizCandidates(): void
{
$season = $this->getSeasonByCode('krtek');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
$candidate = $this->getCandidateBySeasonAndName($season, 'Claudia');
$quizCandidate = new QuizCandidate($quiz, $candidate);
$this->entityManager->persist($quizCandidate);
$this->entityManager->flush();
$this->entityManager->remove($quizCandidate);
$this->entityManager->flush();
$this->entityManager->clear();
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$zip->close();
$rows = $this->loadSheet($quizContent, 'Results')->toArray();
$deletedColumnIndex = array_search('Deleted', $rows[0], true);
$this->assertIsInt($deletedColumnIndex);
$hasDeletedRow = array_any(\array_slice($rows, 1), static fn (array $row): bool => null !== $row[$deletedColumnIndex] && '' !== $row[$deletedColumnIndex]);
$this->assertTrue($hasDeletedRow, 'Expected the soft-deleted QuizCandidate to still appear with a Deleted timestamp');
}
public function testRawAnswersSheetShowsCandidatesByQuestionsGrid(): void
{
$season = $this->getSeasonByCode('krtek');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
$candidate = $this->getCandidateBySeasonAndName($season, 'Claudia');
/** @var Question $firstQuestion */
$firstQuestion = $quiz->questions->first();
$chosenAnswer = $firstQuestion->answers->filter(static fn (Answer $answer): bool => 'Man' === $answer->text)->first();
$this->assertInstanceOf(Answer::class, $chosenAnswer);
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
$givenAnswer = new GivenAnswer($candidate, $quiz, $chosenAnswer);
$this->entityManager->persist($givenAnswer);
$this->entityManager->flush();
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$zip->close();
$rows = $this->loadSheet($quizContent, 'Raw answers')->toArray();
$header = $rows[0];
$this->assertSame('Candidate', $header[0]);
$questionColumnIndex = array_search($firstQuestion->question, $header, true);
$this->assertIsInt($questionColumnIndex);
$claudiaRow = current(array_filter(
\array_slice($rows, 1),
static fn (array $row): bool => 'Claudia' === $row[0],
));
$this->assertIsArray($claudiaRow);
$this->assertSame('Man', $claudiaRow[$questionColumnIndex]);
}
public function testQuizInfoSheetShowsDropoutsFinalizationAndDisabledQuestions(): void
{
$season = $this->getSeasonByCode('krtek');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
$this->assertTrue($quiz->isFinalized);
/** @var Question $disabledQuestion */
$disabledQuestion = $quiz->questions->first();
$disabledQuestion->enabled = false;
$this->entityManager->flush();
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$zip->close();
$rows = $this->loadSheet($quizContent, 'Quiz info')->toArray();
$values = [];
foreach ($rows as $row) {
$values[$row[0]] = $row[1];
}
$this->assertSame('Quiz 1', $values['Quiz name']);
$this->assertSame($quiz->dropouts, (int) $values['Number of dropouts']);
$this->assertSame('Yes', $values['Finalized']);
$this->assertNotEmpty($values['Finalized at']);
$this->assertStringContainsString($disabledQuestion->question, (string) $values['Disabled questions']);
}
private function openZip(User $user): \ZipArchive
{
$zipPath = $this->subject->exportForUser($user);
$this->tempFiles[] = $zipPath;
$zip = new \ZipArchive();
$this->assertTrue($zip->open($zipPath));
return $zip;
}
/** @return list<string> */
private function entryNames(\ZipArchive $zip): array
{
$names = [];
for ($i = 0; $i < $zip->numFiles; ++$i) {
$name = $zip->getNameIndex($i);
$this->assertIsString($name);
$names[] = $name;
}
return $names;
}
/** @return list<string> */
private function sheetNames(string $xlsxContent): array
{
$path = $this->createTempPath();
file_put_contents($path, $xlsxContent);
return array_values(new Reader\Xlsx()->load($path)->getSheetNames());
}
private function loadSheet(string $xlsxContent, string $sheetName): Worksheet
{
$path = $this->createTempPath();
file_put_contents($path, $xlsxContent);
$sheet = new Reader\Xlsx()->load($path)->getSheetByName($sheetName);
$this->assertInstanceOf(Worksheet::class, $sheet);
return $sheet;
}
private function createTempPath(): string
{
$path = tempnam(sys_get_temp_dir(), 'tvdt_export_test_');
$this->tempFiles[] = $path;
return $path;
}
}