|
|
@@ -0,0 +1,151 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace App\Tests\Integration\Audit;
|
|
|
+
|
|
|
+use App\Domain\Auth\Role;
|
|
|
+use App\Domain\Auth\TokenKind;
|
|
|
+use App\Tests\Integration\Support\AppTestCase;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Confirms that successful state-changing admin endpoints emit exactly
|
|
|
+ * one row in `audit_log` and that the actor attribution honours the
|
|
|
+ * SPEC §8 invariant: service-token + impersonation -> actor_kind=user;
|
|
|
+ * raw admin token -> actor_kind=admin-token.
|
|
|
+ */
|
|
|
+final class AuditEmissionTest extends AppTestCase
|
|
|
+{
|
|
|
+ public function testManualBlockCreateEmitsAuditViaAdminToken(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, Role::Admin);
|
|
|
+ $resp = $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/admin/manual-blocks',
|
|
|
+ [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ],
|
|
|
+ (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.42', 'reason' => 'admin-token-test']),
|
|
|
+ );
|
|
|
+ self::assertSame(201, $resp->getStatusCode());
|
|
|
+
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
+ "SELECT actor_kind, actor_id, action, target_type, details_json FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
|
|
|
+ );
|
|
|
+ self::assertIsArray($row);
|
|
|
+ self::assertSame('admin-token', $row['actor_kind']);
|
|
|
+ self::assertSame('manual_block', $row['target_type']);
|
|
|
+ $details = json_decode((string) $row['details_json'], true);
|
|
|
+ self::assertIsArray($details);
|
|
|
+ self::assertSame('203.0.113.42', $details['ip']);
|
|
|
+ self::assertSame('admin-token-test', $details['reason']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testManualBlockCreateEmitsAuditAttributedToImpersonatedUser(): void
|
|
|
+ {
|
|
|
+ $userId = $this->createUser(Role::Admin, isLocal: true);
|
|
|
+ $service = $this->createToken(TokenKind::Service);
|
|
|
+ $resp = $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/admin/manual-blocks',
|
|
|
+ [
|
|
|
+ 'Authorization' => 'Bearer ' . $service,
|
|
|
+ 'X-Acting-User-Id' => (string) $userId,
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
+ ],
|
|
|
+ (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.43', 'reason' => 'via-ui']),
|
|
|
+ );
|
|
|
+ self::assertSame(201, $resp->getStatusCode());
|
|
|
+
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
+ "SELECT actor_kind, actor_id FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
|
|
|
+ );
|
|
|
+ self::assertIsArray($row);
|
|
|
+ self::assertSame('user', $row['actor_kind']);
|
|
|
+ self::assertSame((string) $userId, $row['actor_id']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testTokenCreateNeverPutsRawTokenInAudit(): void
|
|
|
+ {
|
|
|
+ $admin = $this->createToken(TokenKind::Admin, Role::Admin);
|
|
|
+ $reporterId = $this->createReporter('rep-audit');
|
|
|
+ $resp = $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/admin/tokens',
|
|
|
+ ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
|
|
|
+ (string) json_encode(['kind' => 'reporter', 'reporter_id' => $reporterId]),
|
|
|
+ );
|
|
|
+ self::assertSame(201, $resp->getStatusCode());
|
|
|
+ $body = $this->decode($resp);
|
|
|
+ $rawToken = (string) $body['raw_token'];
|
|
|
+
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
+ "SELECT details_json FROM audit_log WHERE action = 'token.created' ORDER BY id DESC LIMIT 1"
|
|
|
+ );
|
|
|
+ self::assertIsArray($row);
|
|
|
+ $json = (string) $row['details_json'];
|
|
|
+ self::assertStringNotContainsString($rawToken, $json);
|
|
|
+ // Prefix is allowed.
|
|
|
+ $details = json_decode($json, true);
|
|
|
+ self::assertIsArray($details);
|
|
|
+ self::assertArrayHasKey('prefix', $details);
|
|
|
+ self::assertArrayHasKey('kind', $details);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testFailedValidationDoesNotEmitAudit(): void
|
|
|
+ {
|
|
|
+ $admin = $this->createToken(TokenKind::Admin, Role::Admin);
|
|
|
+ $resp = $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/admin/manual-blocks',
|
|
|
+ ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
|
|
|
+ (string) json_encode(['kind' => 'ip', 'ip' => 'not-an-ip']),
|
|
|
+ );
|
|
|
+ self::assertSame(400, $resp->getStatusCode());
|
|
|
+
|
|
|
+ $count = (int) $this->db->fetchOne("SELECT COUNT(*) FROM audit_log WHERE action = 'manual_block.created'");
|
|
|
+ self::assertSame(0, $count);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testCategoryCreateUpdateDeleteAudit(): void
|
|
|
+ {
|
|
|
+ $admin = $this->createToken(TokenKind::Admin, Role::Admin);
|
|
|
+ $resp = $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/admin/categories',
|
|
|
+ ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
|
|
|
+ (string) json_encode([
|
|
|
+ 'slug' => 'audit_test',
|
|
|
+ 'name' => 'Audit Test',
|
|
|
+ 'decay_function' => 'linear',
|
|
|
+ 'decay_param' => 30.0,
|
|
|
+ ]),
|
|
|
+ );
|
|
|
+ self::assertSame(201, $resp->getStatusCode());
|
|
|
+ $created = $this->decode($resp);
|
|
|
+ $id = (int) $created['id'];
|
|
|
+
|
|
|
+ $resp = $this->request(
|
|
|
+ 'PATCH',
|
|
|
+ '/api/v1/admin/categories/' . $id,
|
|
|
+ ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
|
|
|
+ (string) json_encode(['name' => 'Audit Test Renamed']),
|
|
|
+ );
|
|
|
+ self::assertSame(200, $resp->getStatusCode());
|
|
|
+
|
|
|
+ $resp = $this->request(
|
|
|
+ 'DELETE',
|
|
|
+ '/api/v1/admin/categories/' . $id,
|
|
|
+ ['Authorization' => 'Bearer ' . $admin],
|
|
|
+ );
|
|
|
+ self::assertSame(204, $resp->getStatusCode());
|
|
|
+
|
|
|
+ $rows = $this->db->fetchAllAssociative(
|
|
|
+ "SELECT action FROM audit_log WHERE target_type = 'category' AND target_id = ? ORDER BY id ASC",
|
|
|
+ [(string) $id],
|
|
|
+ );
|
|
|
+ $actions = array_map(static fn ($r) => $r['action'], $rows);
|
|
|
+ self::assertSame(['category.created', 'category.updated', 'category.deleted'], $actions);
|
|
|
+ }
|
|
|
+}
|