|
|
@@ -0,0 +1,233 @@
|
|
|
+<?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;
|
|
|
+ }
|
|
|
+}
|