process(sprintf( 'Request: "GET https://saas.example/api/v2/time/account/%s/theo%%40example.com/2026/2"', self::KEY, )); $this->assertStringNotContainsString(self::KEY, $record->message); $this->assertStringContainsString('/time/account/[redacted]', $record->message); $this->assertStringContainsString('theo%40example.com/2026/2', $record->message, 'Only the key goes.'); } /** * The one a silenced client would have missed: an HTTP error repeats the url inside the * exception message, which then travels into whichever channel catches it. */ public function testItRedactsTheKeyOutOfAnExceptionMessage(): void { $record = $this->process( 'Work hours import failed', ['error' => sprintf('HTTP 500 returned for "https://saas.example/api/v2/time/account/%s/a@b.de/2026/2".', self::KEY)], ); $this->assertStringNotContainsString(self::KEY, json_encode($record->context)); $this->assertStringContainsString('[redacted]', $record->context['error']); } public function testItRedactsTheRosterAndDepartmentEndpointsToo(): void { $record = $this->process(sprintf( 'GET https://saas.example/api/v2/employees/%s and /api/v2/employee/departments/%s', self::KEY, self::KEY, )); $this->assertStringNotContainsString(self::KEY, $record->message); $this->assertStringContainsString('/employees/[redacted]', $record->message); $this->assertStringContainsString('/employee/departments/[redacted]', $record->message); } /** * The roster hands out a key per employee. None is configured here, so only the pattern * can catch those - which is why the pattern pass exists alongside the exact one. */ public function testAnUnknownKeyInAKeyPositionIsStillRedacted(): void { $record = $this->process('Request: "GET https://saas.example/api/v2/time/account/some-other-key/a@b.de/2026/2"'); $this->assertStringNotContainsString('some-other-key', $record->message); } public function testItReachesIntoNestedContext(): void { $record = $this->process('failed', [ 'request' => ['url' => 'https://saas.example/api/v2/employees/'.self::KEY], ]); $this->assertStringNotContainsString(self::KEY, json_encode($record->context)); } public function testItLeavesOrdinaryRecordsAlone(): void { $record = $this->process('Work hours imported', ['teamer' => 'Test, Theo', 'minutes' => 480]); $this->assertSame('Work hours imported', $record->message); $this->assertSame(['teamer' => 'Test, Theo', 'minutes' => 480], $record->context); } /** * An unconfigured key is an empty string, which must never be treated as a secret to * replace - that would rewrite every character of every message. */ public function testAnUnconfiguredKeyRedactsNothingByValue(): void { $processor = new RedactSaasCredentialsProcessor(''); $record = $processor(new LogRecord(new \DateTimeImmutable(), 'app', Level::Info, 'plain message')); $this->assertSame('plain message', $record->message); } private function process(string $message, array $context = []): LogRecord { $processor = new RedactSaasCredentialsProcessor(self::KEY); return $processor(new LogRecord(new \DateTimeImmutable(), 'app', Level::Info, $message, $context)); } }