1
0

CspReportControllerTest.php 9.0 KB

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