1
0

PublicEndpointAuditTest.php 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Audit;
  4. use App\Domain\Auth\TokenKind;
  5. use App\Domain\Settings\AppSettings;
  6. use App\Tests\Integration\Support\AppTestCase;
  7. /**
  8. * SPEC §M12 covers admin-side audit emission. C20 adds two public-endpoint
  9. * audit entries — `report.received` and `blocklist.requested` — gated by
  10. * runtime feature flags so the high-volume rows can be silenced without
  11. * restarting the api.
  12. */
  13. final class PublicEndpointAuditTest extends AppTestCase
  14. {
  15. public function testReportReceivedEmitsAuditWithReporterActor(): void
  16. {
  17. $reporterId = $this->createReporter('rep-audit');
  18. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  19. $resp = $this->request(
  20. 'POST',
  21. '/api/v1/report',
  22. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  23. (string) json_encode(['ip' => '203.0.113.42', 'category' => 'brute_force', 'metadata' => ['ua' => 'curl']]),
  24. );
  25. self::assertSame(202, $resp->getStatusCode());
  26. $row = $this->db->fetchAssociative(
  27. "SELECT actor_kind, actor_id, action, target_type, target_label, details_json FROM audit_log WHERE action = 'report.received' ORDER BY id DESC LIMIT 1"
  28. );
  29. self::assertIsArray($row);
  30. self::assertSame('reporter', $row['actor_kind']);
  31. self::assertSame((string) $reporterId, $row['actor_id']);
  32. self::assertSame('report', $row['target_type']);
  33. self::assertSame('203.0.113.42', $row['target_label']);
  34. $details = json_decode((string) $row['details_json'], true);
  35. self::assertIsArray($details);
  36. self::assertSame('203.0.113.42', $details['ip']);
  37. self::assertSame('brute_force', $details['category']);
  38. self::assertSame($reporterId, $details['reporter_id']);
  39. self::assertSame('rep-audit', $details['reporter_name']);
  40. self::assertTrue($details['has_metadata']);
  41. }
  42. public function testReportReceivedSuppressedWhenToggleDisabled(): void
  43. {
  44. /** @var AppSettings $settings */
  45. $settings = $this->container->get(AppSettings::class);
  46. $settings->setBool(AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED, false);
  47. $reporterId = $this->createReporter('rep-quiet');
  48. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  49. $resp = $this->request(
  50. 'POST',
  51. '/api/v1/report',
  52. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  53. (string) json_encode(['ip' => '198.51.100.7', 'category' => 'scanner']),
  54. );
  55. self::assertSame(202, $resp->getStatusCode());
  56. $count = (int) $this->db->fetchOne(
  57. "SELECT COUNT(*) FROM audit_log WHERE action = 'report.received'"
  58. );
  59. self::assertSame(0, $count);
  60. }
  61. public function testBlocklistRequestedEmitsAuditWithConsumerActor(): void
  62. {
  63. $token = $this->setupConsumerToken('moderate', 'fw-audit');
  64. $resp = $this->request('GET', '/api/v1/blocklist', [
  65. 'Authorization' => 'Bearer ' . $token,
  66. ]);
  67. self::assertSame(200, $resp->getStatusCode());
  68. $row = $this->db->fetchAssociative(
  69. "SELECT actor_kind, actor_id, action, target_type, target_label, details_json FROM audit_log WHERE action = 'blocklist.requested' ORDER BY id DESC LIMIT 1"
  70. );
  71. self::assertIsArray($row);
  72. self::assertSame('consumer', $row['actor_kind']);
  73. self::assertNotNull($row['actor_id']);
  74. self::assertSame('blocklist', $row['target_type']);
  75. self::assertSame('moderate', $row['target_label']);
  76. $details = json_decode((string) $row['details_json'], true);
  77. self::assertIsArray($details);
  78. self::assertSame('moderate', $details['policy_name']);
  79. self::assertSame('text', $details['format']);
  80. self::assertSame(200, $details['status']);
  81. self::assertArrayHasKey('entries', $details);
  82. }
  83. public function testBlocklist304StillEmitsAuditWithStatus304(): void
  84. {
  85. $token = $this->setupConsumerToken('moderate', 'fw-etag');
  86. $first = $this->request('GET', '/api/v1/blocklist', [
  87. 'Authorization' => 'Bearer ' . $token,
  88. ]);
  89. $etag = $first->getHeaderLine('ETag');
  90. $second = $this->request('GET', '/api/v1/blocklist', [
  91. 'Authorization' => 'Bearer ' . $token,
  92. 'If-None-Match' => $etag,
  93. ]);
  94. self::assertSame(304, $second->getStatusCode());
  95. $statuses = $this->db->fetchAllAssociative(
  96. "SELECT details_json FROM audit_log WHERE action = 'blocklist.requested' ORDER BY id ASC"
  97. );
  98. self::assertCount(2, $statuses);
  99. $second = json_decode((string) $statuses[1]['details_json'], true);
  100. self::assertIsArray($second);
  101. self::assertSame(304, $second['status']);
  102. }
  103. public function testBlocklistRequestedSuppressedWhenToggleDisabled(): void
  104. {
  105. /** @var AppSettings $settings */
  106. $settings = $this->container->get(AppSettings::class);
  107. $settings->setBool(AppSettings::KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED, false);
  108. $token = $this->setupConsumerToken('moderate', 'fw-quiet');
  109. $resp = $this->request('GET', '/api/v1/blocklist', [
  110. 'Authorization' => 'Bearer ' . $token,
  111. ]);
  112. self::assertSame(200, $resp->getStatusCode());
  113. $count = (int) $this->db->fetchOne(
  114. "SELECT COUNT(*) FROM audit_log WHERE action = 'blocklist.requested'"
  115. );
  116. self::assertSame(0, $count);
  117. }
  118. public function testReportSuppressedWhenReporterAuditDisabled(): void
  119. {
  120. $reporterId = $this->createReporter('rep-silent');
  121. $this->db->update('reporters', ['audit_enabled' => 0], ['id' => $reporterId]);
  122. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  123. $resp = $this->request(
  124. 'POST',
  125. '/api/v1/report',
  126. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  127. (string) json_encode(['ip' => '203.0.113.99', 'category' => 'spam']),
  128. );
  129. self::assertSame(202, $resp->getStatusCode());
  130. $count = (int) $this->db->fetchOne(
  131. "SELECT COUNT(*) FROM audit_log WHERE action = 'report.received'"
  132. );
  133. self::assertSame(0, $count);
  134. }
  135. public function testBlocklistSuppressedWhenConsumerAuditDisabled(): void
  136. {
  137. $token = $this->setupConsumerToken('moderate', 'fw-silent');
  138. $this->db->update('consumers', ['audit_enabled' => 0], ['name' => 'fw-silent']);
  139. $resp = $this->request('GET', '/api/v1/blocklist', [
  140. 'Authorization' => 'Bearer ' . $token,
  141. ]);
  142. self::assertSame(200, $resp->getStatusCode());
  143. $count = (int) $this->db->fetchOne(
  144. "SELECT COUNT(*) FROM audit_log WHERE action = 'blocklist.requested'"
  145. );
  146. self::assertSame(0, $count);
  147. }
  148. public function testFailedReportDoesNotEmitAudit(): void
  149. {
  150. $reporterId = $this->createReporter('rep-bad');
  151. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  152. $resp = $this->request(
  153. 'POST',
  154. '/api/v1/report',
  155. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  156. (string) json_encode(['ip' => 'not-an-ip', 'category' => 'spam']),
  157. );
  158. self::assertSame(400, $resp->getStatusCode());
  159. $count = (int) $this->db->fetchOne(
  160. "SELECT COUNT(*) FROM audit_log WHERE action = 'report.received'"
  161. );
  162. self::assertSame(0, $count);
  163. }
  164. private function setupConsumerToken(string $policyName, string $consumerName): string
  165. {
  166. $policyId = (int) $this->db->fetchOne(
  167. 'SELECT id FROM policies WHERE name = :n',
  168. ['n' => $policyName],
  169. );
  170. $this->db->insert('consumers', [
  171. 'name' => $consumerName,
  172. 'policy_id' => $policyId,
  173. 'is_active' => 1,
  174. ]);
  175. $consumerId = (int) $this->db->lastInsertId();
  176. return $this->createToken(TokenKind::Consumer, consumerId: $consumerId);
  177. }
  178. }