* SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ declare(strict_types=1); namespace App\Tests\Controllers; use App\Controllers\AuditController; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** * R01-N12: pin AuditController::validateDateFilters. The repo concatenates * "T00:00:00Z" / "T23:59:59Z" onto whatever string it gets and binds the * result against the ISO-8601 `occurred_at` column — garbage in silently * hides rows. The controller is the gate; this test pins which inputs * pass through and which get dropped + flagged. */ final class AuditControllerTest extends TestCase { /** * @return list,string}> */ public static function dateCases(): array { // [from, to, expectedFrom, expectedTo, expectedErrorKeys, label] return [ ['', '', '', '', [], 'no filters → no errors'], ['2026-01-31', '', '2026-01-31', '', [], 'from-only valid'], ['', '2026-12-31', '', '2026-12-31', [], 'to-only valid'], ['2026-01-01', '2026-12-31', '2026-01-01', '2026-12-31', [], 'both valid'], ['2024/01/01', '', '', '', ['from_date'], 'slashes → from dropped, error reported'], ['', '2024/01/01', '', '', ['to_date'], 'slashes → to dropped, error reported'], ['2024/01/01', '2024/12/31', '', '', ['from_date', 'to_date'], 'both garbage → both dropped, both errors'], ['2026-01-31', 'tomorrow', '2026-01-31', '', ['to_date'], 'one-side invalid → only that side errors, the other passes'], ['2026-13-01', '', '', '', ['from_date'], 'impossible month → rejected'], ['2026-02-30', '', '', '', ['from_date'], 'impossible day for month → rejected (strict round-trip)'], ['2026-1-1', '', '', '', ['from_date'], 'unpadded → rejected (strict round-trip)'], [' 2026-01-01', '', '', '', ['from_date'], 'leading space → rejected'], ['2026-01-01 ', '', '', '', ['from_date'], 'trailing space → rejected'], ["'; DROP TABLE audit_log; --", '', '', '', ['from_date'], 'injection-shaped string → rejected'], ]; } /** @param list $expectedErrorKeys */ #[DataProvider('dateCases')] public function testValidateDateFilters( string $from, string $to, string $expectedFrom, string $expectedTo, array $expectedErrorKeys, string $label, ): void { $out = AuditController::validateDateFilters($from, $to); $this->assertSame($expectedFrom, $out['from'], "{$label}: from"); $this->assertSame($expectedTo, $out['to'], "{$label}: to"); $this->assertSame( $expectedErrorKeys, array_keys($out['errors']), "{$label}: error keys", ); // Error messages are non-empty when present (keeps the contract for // the view, which renders {{ dateErrors.from_date }} verbatim). foreach ($out['errors'] as $key => $msg) { $this->assertNotSame('', $msg, "{$label}: error message for {$key} is non-empty"); } } public function testValidatorPreservesValidValuesVerbatim(): void { // Boundary that the repo concatenates onto: a leap day. Confirms // we don't normalise or reformat — the repo gets exactly what the // user typed (after gating). $out = AuditController::validateDateFilters('2024-02-29', '2024-02-29'); $this->assertSame('2024-02-29', $out['from']); $this->assertSame('2024-02-29', $out['to']); $this->assertSame([], $out['errors']); } public function testValidatorRejectsNonLeapFebTwentyNine(): void { // 2025 is not a leap year. createFromFormat is lenient and silently // rolls over to 2025-03-01; the round-trip equality check we use // is what catches it. $out = AuditController::validateDateFilters('2025-02-29', ''); $this->assertSame('', $out['from']); $this->assertArrayHasKey('from_date', $out['errors']); } }