| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Controllers;
- use App\Controllers\CspReportController;
- use App\Http\Request;
- use App\Services\AuditLogger;
- use App\Tests\TestCase;
- use PHPUnit\Framework\Attributes\DataProvider;
- use ReflectionClass;
- /**
- * R01-N19: pin the contract of CspReportController.
- *
- * The controller has two halves:
- *
- * - `extractReport()` — pure decoder: bytes → array | null. Easy to
- * pin with a data-providery sweep over good and adversarial inputs.
- * - `report()` — IO half: takes a Request, writes one audit row, hands
- * back a 204. Validated end-to-end against a real (in-memory) DB so
- * we catch shape drift in the audit insert at the same time.
- */
- final class CspReportControllerTest extends TestCase
- {
- public function testExtractReportUnwrapsCspReportEnvelope(): void
- {
- $body = json_encode(['csp-report' => [
- 'document-uri' => 'https://app.example.com/sprints/1',
- 'violated-directive' => "script-src 'self'",
- 'blocked-uri' => 'https://attacker.example/x.js',
- ]], JSON_THROW_ON_ERROR);
- $out = CspReportController::extractReport($body);
- self::assertIsArray($out);
- self::assertSame('https://app.example.com/sprints/1', $out['document-uri']);
- self::assertSame("script-src 'self'", $out['violated-directive']);
- self::assertArrayNotHasKey('csp-report', $out, 'inner object is unwrapped, not nested');
- }
- public function testExtractReportAcceptsBareJsonObject(): void
- {
- $body = '{"document-uri":"https://x.example/","violated-directive":"img-src"}';
- $out = CspReportController::extractReport($body);
- self::assertIsArray($out);
- self::assertSame('https://x.example/', $out['document-uri']);
- }
- /**
- * @return list<array{0:string,1:string}>
- */
- public static function malformedBodies(): array
- {
- return [
- ['', 'empty body'],
- ['not json at all', 'free text'],
- ['"a string, not an object"', 'JSON string scalar'],
- ['42', 'JSON number scalar'],
- ['null', 'JSON null'],
- ['[1,2,3]', 'JSON array (top-level non-object)'],
- ['{"csp-report":"not an object"}', 'csp-report not an array → fall through to outer object'],
- ];
- }
- #[DataProvider('malformedBodies')]
- public function testExtractReportRejectsNonObjectAndMalformedBodies(string $body, string $label): void
- {
- $out = CspReportController::extractReport($body);
- // The csp-report-not-an-object case falls through to the outer object,
- // which IS an object — so we accept that one as a non-null array.
- if ($body === '{"csp-report":"not an object"}') {
- self::assertIsArray($out, "{$label}: outer object survives unwrap miss");
- self::assertSame('not an object', $out['csp-report']);
- return;
- }
- self::assertNull($out, "{$label}: should be unparseable");
- }
- public function testReportWritesAuditRowAnd204(): void
- {
- $pdo = $this->makeDb();
- $audit = new AuditLogger($pdo);
- $ctrl = new CspReportController($audit);
- $report = json_encode(['csp-report' => [
- 'document-uri' => 'https://app.example.com/audit',
- 'referrer' => '',
- 'violated-directive' => "script-src 'self'",
- 'effective-directive' => "script-src",
- 'original-policy' => "default-src 'self'; report-uri /csp-report",
- 'disposition' => 'enforce',
- 'blocked-uri' => 'inline',
- 'line-number' => 12,
- 'source-file' => 'https://app.example.com/audit',
- 'status-code' => 200,
- 'script-sample' => '',
- ]], JSON_THROW_ON_ERROR);
- $req = $this->makeRequest('POST', '/csp-report', $report, [
- 'content-type' => 'application/csp-report',
- 'user-agent' => 'Mozilla/5.0 (TestRunner)',
- ], '203.0.113.10');
- $resp = $ctrl->report($req);
- self::assertSame(204, $resp->status);
- self::assertSame('', $resp->body);
- $rows = $pdo->query(
- 'SELECT action, entity_type, entity_id, before_json, after_json,
- user_id, user_email, ip_address, user_agent
- FROM audit_log'
- )->fetchAll();
- self::assertCount(1, $rows, 'exactly one audit row written');
- $row = $rows[0];
- self::assertSame('CSP_VIOLATION', $row['action']);
- self::assertSame('csp_violation', $row['entity_type']);
- self::assertNull($row['entity_id']);
- self::assertNull($row['before_json']);
- self::assertNull($row['user_id']);
- self::assertNull($row['user_email']);
- self::assertSame('203.0.113.10', $row['ip_address']);
- self::assertSame('Mozilla/5.0 (TestRunner)', $row['user_agent']);
- $after = json_decode((string) $row['after_json'], true);
- self::assertIsArray($after);
- self::assertSame('https://app.example.com/audit', $after['document-uri']);
- self::assertSame("script-src 'self'", $after['violated-directive']);
- self::assertArrayNotHasKey('csp-report', $after);
- }
- public function testReportWithEmptyBodyWritesNoAuditRow(): void
- {
- $pdo = $this->makeDb();
- $audit = new AuditLogger($pdo);
- $ctrl = new CspReportController($audit);
- $req = $this->makeRequest('POST', '/csp-report', '', [], '198.51.100.1');
- $resp = $ctrl->report($req);
- self::assertSame(204, $resp->status);
- self::assertSame(
- 0,
- (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn(),
- 'empty body → no row',
- );
- }
- public function testReportRejectsBodyOverCap(): void
- {
- $pdo = $this->makeDb();
- $audit = new AuditLogger($pdo);
- $ctrl = new CspReportController($audit);
- // Build a JSON object whose serialized form exceeds the 16 KiB cap.
- $padding = str_repeat('a', CspReportController::MAX_BODY_BYTES + 1);
- $body = json_encode(['csp-report' => ['document-uri' => $padding]], JSON_THROW_ON_ERROR);
- self::assertGreaterThan(CspReportController::MAX_BODY_BYTES, strlen($body));
- $req = $this->makeRequest('POST', '/csp-report', $body, [
- 'content-type' => 'application/csp-report',
- ], '198.51.100.2');
- $resp = $ctrl->report($req);
- self::assertSame(204, $resp->status, 'still 204 — endpoint reveals nothing');
- self::assertSame(
- 0,
- (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn(),
- 'oversized body → no row',
- );
- }
- public function testReportRejectsNonJsonBody(): void
- {
- $pdo = $this->makeDb();
- $audit = new AuditLogger($pdo);
- $ctrl = new CspReportController($audit);
- $req = $this->makeRequest('POST', '/csp-report', 'plain text junk', [
- 'content-type' => 'text/plain',
- ], '198.51.100.3');
- $resp = $ctrl->report($req);
- self::assertSame(204, $resp->status);
- self::assertSame(
- 0,
- (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn(),
- );
- }
- public function testMaxBodyBytesIsExactly16KiB(): void
- {
- // Drift fence: a future bump must update REVIEW_01.md and the
- // controller phpdoc together with the constant.
- self::assertSame(16 * 1024, CspReportController::MAX_BODY_BYTES);
- }
- /**
- * @param array<string,string> $headers lower-cased names
- */
- private function makeRequest(
- string $method,
- string $path,
- string $rawBody,
- array $headers = [],
- string $remoteAddr = '127.0.0.1',
- ): Request {
- // The Request constructor is `public readonly` — we hand-build it
- // via reflection so we don't have to mutate $_SERVER / $_POST.
- $server = [
- 'REMOTE_ADDR' => $remoteAddr,
- 'REQUEST_METHOD' => $method,
- 'REQUEST_URI' => $path,
- ];
- $r = (new ReflectionClass(Request::class))->newInstanceArgs([
- 'method' => $method,
- 'path' => $path,
- 'query' => [],
- 'post' => [],
- 'rawBody' => $rawBody,
- 'headers' => $headers,
- 'server' => $server,
- ]);
- return $r;
- }
- }
|