", failregex sees the rest. private const F2B_DATE = '/^[A-Z]+ - \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/'; private const F2B_FAILREGEX = '/^\s*--> LOGIN_FAILED user=.* ip=(\S+)\s*$/'; private const EXACT_FORMAT = '/^WARNING - \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} --> LOGIN_FAILED user=[A-Za-z0-9_@.\-+]{1,64} ip=\S+$/'; private function logFile(): string { return WRITEPATH . 'logs/log-' . date('Y-m-d') . '.log'; } protected function setUp(): void { @unlink($this->logFile()); } /** @return string[] log lines without the PHP-header line */ private function logLines(): array { if (!is_file($this->logFile())) { return []; } return array_values(array_filter( file($this->logFile(), FILE_IGNORE_NEW_LINES), static fn($l) => $l !== '' && !str_starts_with($l, 'newInstanceWithoutConstructor(); $request = new class($ip) { public function __construct(private string $ip) {} public function getIPAddress(): string { return $this->ip; } }; $prop = new ReflectionProperty(Login::class, 'request'); $prop->setValue($controller, $request); (new ReflectionMethod(Login::class, 'logLoginFailed'))->invoke($controller, $user); } public function testFailedLoginWritesExactlyOneLineInExactFormat(): void { $this->failLogin('jan.jansen@example.nl'); $lines = $this->logLines(); $this->assertCount(1, $lines); $this->assertMatchesRegularExpression(self::EXACT_FORMAT, $lines[0]); $this->assertStringEndsWith('--> LOGIN_FAILED user=jan.jansen@example.nl ip=203.0.113.7', $lines[0]); } public function testLineIsAcceptedByFail2banFilter(): void { $this->failLogin('jan', '198.51.100.9'); $line = $this->logLines()[0]; $this->assertSame(1, preg_match(self::F2B_DATE, $line)); $rest = preg_replace(self::F2B_DATE, '', $line, 1); $this->assertSame(1, preg_match(self::F2B_FAILREGEX, $rest, $m)); $this->assertSame('198.51.100.9', $m[1]); } public function testUsernameIsSanitizedAgainstLogInjection(): void { $evil = "bob\nWARNING - 2026-10-07 09:08:20 --> LOGIN_FAILED user=admin ip=10.0.0.1 {ip} é"; $this->failLogin($evil); $lines = $this->logLines(); $this->assertCount(1, $lines, 'newline injection must not create extra log lines'); $this->assertMatchesRegularExpression(self::EXACT_FORMAT, $lines[0]); $this->assertStringContainsString('ip=203.0.113.7', $lines[0]); } public function testSanitizeLogUser(): void { $this->assertSame('a.b-c_d@e+f', sanitize_log_user('a.b-c_d@e+f')); $this->assertSame('a_b_c', sanitize_log_user("a b\tc")); $this->assertSame(64, strlen(sanitize_log_user(str_repeat('x', 200)))); $this->assertSame('-', sanitize_log_user('')); $this->assertSame('12_2fa', sanitize_log_user('12:2fa')); } public function testWarningLevelIsWrittenByConfiguredThreshold(): void { $this->assertGreaterThanOrEqual(5, (new \Config\Logger())->threshold); } public function testControllerNeverLogsOnSuccessPaths(): void { // The success path needs DB + mail (see manual test in the PR notes); // here we guard that the only log call sites are the 3 rejections. $src = file_get_contents(APPPATH . 'Controllers/Login.php'); $this->assertSame(3, substr_count($src, '$this->logLoginFailed(')); } }