1
0

CspReportControllerTest.php 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Controllers;
  4. use App\Controllers\CspReportController;
  5. use App\Http\Request;
  6. use App\Services\AuditLogger;
  7. use App\Tests\TestCase;
  8. use PHPUnit\Framework\Attributes\DataProvider;
  9. use ReflectionClass;
  10. /**
  11. * R01-N19: pin the contract of CspReportController.
  12. *
  13. * The controller has two halves:
  14. *
  15. * - `extractReport()` — pure decoder: bytes → array | null. Easy to
  16. * pin with a data-providery sweep over good and adversarial inputs.
  17. * - `report()` — IO half: takes a Request, writes one audit row, hands
  18. * back a 204. Validated end-to-end against a real (in-memory) DB so
  19. * we catch shape drift in the audit insert at the same time.
  20. */
  21. final class CspReportControllerTest extends TestCase
  22. {
  23. public function testExtractReportUnwrapsCspReportEnvelope(): void
  24. {
  25. $body = json_encode(['csp-report' => [
  26. 'document-uri' => 'https://app.example.com/sprints/1',
  27. 'violated-directive' => "script-src 'self'",
  28. 'blocked-uri' => 'https://attacker.example/x.js',
  29. ]], JSON_THROW_ON_ERROR);
  30. $out = CspReportController::extractReport($body);
  31. self::assertIsArray($out);
  32. self::assertSame('https://app.example.com/sprints/1', $out['document-uri']);
  33. self::assertSame("script-src 'self'", $out['violated-directive']);
  34. self::assertArrayNotHasKey('csp-report', $out, 'inner object is unwrapped, not nested');
  35. }
  36. public function testExtractReportAcceptsBareJsonObject(): void
  37. {
  38. $body = '{"document-uri":"https://x.example/","violated-directive":"img-src"}';
  39. $out = CspReportController::extractReport($body);
  40. self::assertIsArray($out);
  41. self::assertSame('https://x.example/', $out['document-uri']);
  42. }
  43. /**
  44. * @return list<array{0:string,1:string}>
  45. */
  46. public static function malformedBodies(): array
  47. {
  48. return [
  49. ['', 'empty body'],
  50. ['not json at all', 'free text'],
  51. ['"a string, not an object"', 'JSON string scalar'],
  52. ['42', 'JSON number scalar'],
  53. ['null', 'JSON null'],
  54. ['[1,2,3]', 'JSON array (top-level non-object)'],
  55. ['{"csp-report":"not an object"}', 'csp-report not an array → fall through to outer object'],
  56. ];
  57. }
  58. #[DataProvider('malformedBodies')]
  59. public function testExtractReportRejectsNonObjectAndMalformedBodies(string $body, string $label): void
  60. {
  61. $out = CspReportController::extractReport($body);
  62. // The csp-report-not-an-object case falls through to the outer object,
  63. // which IS an object — so we accept that one as a non-null array.
  64. if ($body === '{"csp-report":"not an object"}') {
  65. self::assertIsArray($out, "{$label}: outer object survives unwrap miss");
  66. self::assertSame('not an object', $out['csp-report']);
  67. return;
  68. }
  69. self::assertNull($out, "{$label}: should be unparseable");
  70. }
  71. public function testReportWritesAuditRowAnd204(): void
  72. {
  73. $pdo = $this->makeDb();
  74. $audit = new AuditLogger($pdo);
  75. $ctrl = new CspReportController($audit);
  76. $report = json_encode(['csp-report' => [
  77. 'document-uri' => 'https://app.example.com/audit',
  78. 'referrer' => '',
  79. 'violated-directive' => "script-src 'self'",
  80. 'effective-directive' => "script-src",
  81. 'original-policy' => "default-src 'self'; report-uri /csp-report",
  82. 'disposition' => 'enforce',
  83. 'blocked-uri' => 'inline',
  84. 'line-number' => 12,
  85. 'source-file' => 'https://app.example.com/audit',
  86. 'status-code' => 200,
  87. 'script-sample' => '',
  88. ]], JSON_THROW_ON_ERROR);
  89. $req = $this->makeRequest('POST', '/csp-report', $report, [
  90. 'content-type' => 'application/csp-report',
  91. 'user-agent' => 'Mozilla/5.0 (TestRunner)',
  92. ], '203.0.113.10');
  93. $resp = $ctrl->report($req);
  94. self::assertSame(204, $resp->status);
  95. self::assertSame('', $resp->body);
  96. $rows = $pdo->query(
  97. 'SELECT action, entity_type, entity_id, before_json, after_json,
  98. user_id, user_email, ip_address, user_agent
  99. FROM audit_log'
  100. )->fetchAll();
  101. self::assertCount(1, $rows, 'exactly one audit row written');
  102. $row = $rows[0];
  103. self::assertSame('CSP_VIOLATION', $row['action']);
  104. self::assertSame('csp_violation', $row['entity_type']);
  105. self::assertNull($row['entity_id']);
  106. self::assertNull($row['before_json']);
  107. self::assertNull($row['user_id']);
  108. self::assertNull($row['user_email']);
  109. self::assertSame('203.0.113.10', $row['ip_address']);
  110. self::assertSame('Mozilla/5.0 (TestRunner)', $row['user_agent']);
  111. $after = json_decode((string) $row['after_json'], true);
  112. self::assertIsArray($after);
  113. self::assertSame('https://app.example.com/audit', $after['document-uri']);
  114. self::assertSame("script-src 'self'", $after['violated-directive']);
  115. self::assertArrayNotHasKey('csp-report', $after);
  116. }
  117. public function testReportWithEmptyBodyWritesNoAuditRow(): void
  118. {
  119. $pdo = $this->makeDb();
  120. $audit = new AuditLogger($pdo);
  121. $ctrl = new CspReportController($audit);
  122. $req = $this->makeRequest('POST', '/csp-report', '', [], '198.51.100.1');
  123. $resp = $ctrl->report($req);
  124. self::assertSame(204, $resp->status);
  125. self::assertSame(
  126. 0,
  127. (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn(),
  128. 'empty body → no row',
  129. );
  130. }
  131. public function testReportRejectsBodyOverCap(): void
  132. {
  133. $pdo = $this->makeDb();
  134. $audit = new AuditLogger($pdo);
  135. $ctrl = new CspReportController($audit);
  136. // Build a JSON object whose serialized form exceeds the 16 KiB cap.
  137. $padding = str_repeat('a', CspReportController::MAX_BODY_BYTES + 1);
  138. $body = json_encode(['csp-report' => ['document-uri' => $padding]], JSON_THROW_ON_ERROR);
  139. self::assertGreaterThan(CspReportController::MAX_BODY_BYTES, strlen($body));
  140. $req = $this->makeRequest('POST', '/csp-report', $body, [
  141. 'content-type' => 'application/csp-report',
  142. ], '198.51.100.2');
  143. $resp = $ctrl->report($req);
  144. self::assertSame(204, $resp->status, 'still 204 — endpoint reveals nothing');
  145. self::assertSame(
  146. 0,
  147. (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn(),
  148. 'oversized body → no row',
  149. );
  150. }
  151. public function testReportRejectsNonJsonBody(): void
  152. {
  153. $pdo = $this->makeDb();
  154. $audit = new AuditLogger($pdo);
  155. $ctrl = new CspReportController($audit);
  156. $req = $this->makeRequest('POST', '/csp-report', 'plain text junk', [
  157. 'content-type' => 'text/plain',
  158. ], '198.51.100.3');
  159. $resp = $ctrl->report($req);
  160. self::assertSame(204, $resp->status);
  161. self::assertSame(
  162. 0,
  163. (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn(),
  164. );
  165. }
  166. public function testMaxBodyBytesIsExactly16KiB(): void
  167. {
  168. // Drift fence: a future bump must update REVIEW_01.md and the
  169. // controller phpdoc together with the constant.
  170. self::assertSame(16 * 1024, CspReportController::MAX_BODY_BYTES);
  171. }
  172. /**
  173. * @param array<string,string> $headers lower-cased names
  174. */
  175. private function makeRequest(
  176. string $method,
  177. string $path,
  178. string $rawBody,
  179. array $headers = [],
  180. string $remoteAddr = '127.0.0.1',
  181. ): Request {
  182. // The Request constructor is `public readonly` — we hand-build it
  183. // via reflection so we don't have to mutate $_SERVER / $_POST.
  184. $server = [
  185. 'REMOTE_ADDR' => $remoteAddr,
  186. 'REQUEST_METHOD' => $method,
  187. 'REQUEST_URI' => $path,
  188. ];
  189. $r = (new ReflectionClass(Request::class))->newInstanceArgs([
  190. 'method' => $method,
  191. 'path' => $path,
  192. 'query' => [],
  193. 'post' => [],
  194. 'rawBody' => $rawBody,
  195. 'headers' => $headers,
  196. 'server' => $server,
  197. ]);
  198. return $r;
  199. }
  200. }