Files
myep-team/tests/Logging/RedactSaasCredentialsProcessorTest.php
T

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));
}
}