PublicEndpointAuditTest.php 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  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 testFailedReportDoesNotEmitAudit(): void
  119. {
  120. $reporterId = $this->createReporter('rep-bad');
  121. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  122. $resp = $this->request(
  123. 'POST',
  124. '/api/v1/report',
  125. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  126. (string) json_encode(['ip' => 'not-an-ip', 'category' => 'spam']),
  127. );
  128. self::assertSame(400, $resp->getStatusCode());
  129. $count = (int) $this->db->fetchOne(
  130. "SELECT COUNT(*) FROM audit_log WHERE action = 'report.received'"
  131. );
  132. self::assertSame(0, $count);
  133. }
  134. private function setupConsumerToken(string $policyName, string $consumerName): string
  135. {
  136. $policyId = (int) $this->db->fetchOne(
  137. 'SELECT id FROM policies WHERE name = :n',
  138. ['n' => $policyName],
  139. );
  140. $this->db->insert('consumers', [
  141. 'name' => $consumerName,
  142. 'policy_id' => $policyId,
  143. 'is_active' => 1,
  144. ]);
  145. $consumerId = (int) $this->db->lastInsertId();
  146. return $this->createToken(TokenKind::Consumer, consumerId: $consumerId);
  147. }
  148. }