[ '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 */ 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 $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; } }