104 lines
3.9 KiB
PHP
104 lines
3.9 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Logging;
|
|
|
|
use App\Logging\RedactSaasCredentialsProcessor;
|
|
use Monolog\Level;
|
|
use Monolog\LogRecord;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class RedactSaasCredentialsProcessorTest extends TestCase
|
|
{
|
|
private const KEY = 'sk-live-0123456789abcdef';
|
|
|
|
/**
|
|
* The line Symfony's http client writes for every request it makes.
|
|
*/
|
|
public function testItRedactsTheKeyOutOfARequestLine(): void
|
|
{
|
|
$record = $this->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/[email protected]/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/[email protected]/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));
|
|
}
|
|
}
|