|
@@ -0,0 +1,173 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+declare(strict_types=1);
|
|
|
|
|
+
|
|
|
|
|
+namespace App\Tests\Integration\Audit;
|
|
|
|
|
+
|
|
|
|
|
+use App\Domain\Auth\TokenKind;
|
|
|
|
|
+use App\Domain\Settings\AppSettings;
|
|
|
|
|
+use App\Tests\Integration\Support\AppTestCase;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * SPEC §M12 covers admin-side audit emission. C20 adds two public-endpoint
|
|
|
|
|
+ * audit entries — `report.received` and `blocklist.requested` — gated by
|
|
|
|
|
+ * runtime feature flags so the high-volume rows can be silenced without
|
|
|
|
|
+ * restarting the api.
|
|
|
|
|
+ */
|
|
|
|
|
+final class PublicEndpointAuditTest extends AppTestCase
|
|
|
|
|
+{
|
|
|
|
|
+ public function testReportReceivedEmitsAuditWithReporterActor(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $reporterId = $this->createReporter('rep-audit');
|
|
|
|
|
+ $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
|
|
|
|
|
+
|
|
|
|
|
+ $resp = $this->request(
|
|
|
|
|
+ 'POST',
|
|
|
|
|
+ '/api/v1/report',
|
|
|
|
|
+ ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
|
|
|
|
|
+ (string) json_encode(['ip' => '203.0.113.42', 'category' => 'brute_force', 'metadata' => ['ua' => 'curl']]),
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(202, $resp->getStatusCode());
|
|
|
|
|
+
|
|
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
|
|
+ "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"
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertIsArray($row);
|
|
|
|
|
+ self::assertSame('reporter', $row['actor_kind']);
|
|
|
|
|
+ self::assertSame((string) $reporterId, $row['actor_id']);
|
|
|
|
|
+ self::assertSame('report', $row['target_type']);
|
|
|
|
|
+ self::assertSame('203.0.113.42', $row['target_label']);
|
|
|
|
|
+
|
|
|
|
|
+ $details = json_decode((string) $row['details_json'], true);
|
|
|
|
|
+ self::assertIsArray($details);
|
|
|
|
|
+ self::assertSame('203.0.113.42', $details['ip']);
|
|
|
|
|
+ self::assertSame('brute_force', $details['category']);
|
|
|
|
|
+ self::assertSame($reporterId, $details['reporter_id']);
|
|
|
|
|
+ self::assertSame('rep-audit', $details['reporter_name']);
|
|
|
|
|
+ self::assertTrue($details['has_metadata']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testReportReceivedSuppressedWhenToggleDisabled(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ /** @var AppSettings $settings */
|
|
|
|
|
+ $settings = $this->container->get(AppSettings::class);
|
|
|
|
|
+ $settings->setBool(AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED, false);
|
|
|
|
|
+
|
|
|
|
|
+ $reporterId = $this->createReporter('rep-quiet');
|
|
|
|
|
+ $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
|
|
|
|
|
+
|
|
|
|
|
+ $resp = $this->request(
|
|
|
|
|
+ 'POST',
|
|
|
|
|
+ '/api/v1/report',
|
|
|
|
|
+ ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
|
|
|
|
|
+ (string) json_encode(['ip' => '198.51.100.7', 'category' => 'scanner']),
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(202, $resp->getStatusCode());
|
|
|
|
|
+
|
|
|
|
|
+ $count = (int) $this->db->fetchOne(
|
|
|
|
|
+ "SELECT COUNT(*) FROM audit_log WHERE action = 'report.received'"
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(0, $count);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testBlocklistRequestedEmitsAuditWithConsumerActor(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->setupConsumerToken('moderate', 'fw-audit');
|
|
|
|
|
+ $resp = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ self::assertSame(200, $resp->getStatusCode());
|
|
|
|
|
+
|
|
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
|
|
+ "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"
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertIsArray($row);
|
|
|
|
|
+ self::assertSame('consumer', $row['actor_kind']);
|
|
|
|
|
+ self::assertNotNull($row['actor_id']);
|
|
|
|
|
+ self::assertSame('blocklist', $row['target_type']);
|
|
|
|
|
+ self::assertSame('moderate', $row['target_label']);
|
|
|
|
|
+
|
|
|
|
|
+ $details = json_decode((string) $row['details_json'], true);
|
|
|
|
|
+ self::assertIsArray($details);
|
|
|
|
|
+ self::assertSame('moderate', $details['policy_name']);
|
|
|
|
|
+ self::assertSame('text', $details['format']);
|
|
|
|
|
+ self::assertSame(200, $details['status']);
|
|
|
|
|
+ self::assertArrayHasKey('entries', $details);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testBlocklist304StillEmitsAuditWithStatus304(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->setupConsumerToken('moderate', 'fw-etag');
|
|
|
|
|
+ $first = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $etag = $first->getHeaderLine('ETag');
|
|
|
|
|
+
|
|
|
|
|
+ $second = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ 'If-None-Match' => $etag,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ self::assertSame(304, $second->getStatusCode());
|
|
|
|
|
+
|
|
|
|
|
+ $statuses = $this->db->fetchAllAssociative(
|
|
|
|
|
+ "SELECT details_json FROM audit_log WHERE action = 'blocklist.requested' ORDER BY id ASC"
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertCount(2, $statuses);
|
|
|
|
|
+ $second = json_decode((string) $statuses[1]['details_json'], true);
|
|
|
|
|
+ self::assertIsArray($second);
|
|
|
|
|
+ self::assertSame(304, $second['status']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testBlocklistRequestedSuppressedWhenToggleDisabled(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ /** @var AppSettings $settings */
|
|
|
|
|
+ $settings = $this->container->get(AppSettings::class);
|
|
|
|
|
+ $settings->setBool(AppSettings::KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED, false);
|
|
|
|
|
+
|
|
|
|
|
+ $token = $this->setupConsumerToken('moderate', 'fw-quiet');
|
|
|
|
|
+ $resp = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ self::assertSame(200, $resp->getStatusCode());
|
|
|
|
|
+
|
|
|
|
|
+ $count = (int) $this->db->fetchOne(
|
|
|
|
|
+ "SELECT COUNT(*) FROM audit_log WHERE action = 'blocklist.requested'"
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(0, $count);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testFailedReportDoesNotEmitAudit(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $reporterId = $this->createReporter('rep-bad');
|
|
|
|
|
+ $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
|
|
|
|
|
+
|
|
|
|
|
+ $resp = $this->request(
|
|
|
|
|
+ 'POST',
|
|
|
|
|
+ '/api/v1/report',
|
|
|
|
|
+ ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
|
|
|
|
|
+ (string) json_encode(['ip' => 'not-an-ip', 'category' => 'spam']),
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(400, $resp->getStatusCode());
|
|
|
|
|
+
|
|
|
|
|
+ $count = (int) $this->db->fetchOne(
|
|
|
|
|
+ "SELECT COUNT(*) FROM audit_log WHERE action = 'report.received'"
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(0, $count);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function setupConsumerToken(string $policyName, string $consumerName): string
|
|
|
|
|
+ {
|
|
|
|
|
+ $policyId = (int) $this->db->fetchOne(
|
|
|
|
|
+ 'SELECT id FROM policies WHERE name = :n',
|
|
|
|
|
+ ['n' => $policyName],
|
|
|
|
|
+ );
|
|
|
|
|
+ $this->db->insert('consumers', [
|
|
|
|
|
+ 'name' => $consumerName,
|
|
|
|
|
+ 'policy_id' => $policyId,
|
|
|
|
|
+ 'is_active' => 1,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $consumerId = (int) $this->db->lastInsertId();
|
|
|
|
|
+
|
|
|
|
|
+ return $this->createToken(TokenKind::Consumer, consumerId: $consumerId);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|