AuditEmissionTest.php 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Audit;
  4. use App\Domain\Auth\Role;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Tests\Integration\Support\AppTestCase;
  7. /**
  8. * Confirms that successful state-changing admin endpoints emit exactly
  9. * one row in `audit_log` and that the actor attribution honours the
  10. * SPEC §8 invariant: service-token + impersonation -> actor_kind=user;
  11. * raw admin token -> actor_kind=admin-token.
  12. */
  13. final class AuditEmissionTest extends AppTestCase
  14. {
  15. public function testManualBlockCreateEmitsAuditViaAdminToken(): void
  16. {
  17. $token = $this->createToken(TokenKind::Admin, Role::Admin);
  18. $resp = $this->request(
  19. 'POST',
  20. '/api/v1/admin/manual-blocks',
  21. [
  22. 'Authorization' => 'Bearer ' . $token,
  23. 'Content-Type' => 'application/json',
  24. ],
  25. (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.42', 'reason' => 'admin-token-test']),
  26. );
  27. self::assertSame(201, $resp->getStatusCode());
  28. $row = $this->db->fetchAssociative(
  29. "SELECT actor_kind, actor_id, action, target_type, details_json FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
  30. );
  31. self::assertIsArray($row);
  32. self::assertSame('admin-token', $row['actor_kind']);
  33. self::assertSame('manual_block', $row['target_type']);
  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('admin-token-test', $details['reason']);
  38. }
  39. public function testManualBlockCreateEmitsAuditAttributedToImpersonatedUser(): void
  40. {
  41. $userId = $this->createUser(Role::Admin, isLocal: true);
  42. $service = $this->createToken(TokenKind::Service);
  43. $resp = $this->request(
  44. 'POST',
  45. '/api/v1/admin/manual-blocks',
  46. [
  47. 'Authorization' => 'Bearer ' . $service,
  48. 'X-Acting-User-Id' => (string) $userId,
  49. 'Content-Type' => 'application/json',
  50. ],
  51. (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.43', 'reason' => 'via-ui']),
  52. );
  53. self::assertSame(201, $resp->getStatusCode());
  54. $row = $this->db->fetchAssociative(
  55. "SELECT actor_kind, actor_id FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
  56. );
  57. self::assertIsArray($row);
  58. self::assertSame('user', $row['actor_kind']);
  59. self::assertSame((string) $userId, $row['actor_id']);
  60. }
  61. public function testTokenCreateNeverPutsRawTokenInAudit(): void
  62. {
  63. $admin = $this->createToken(TokenKind::Admin, Role::Admin);
  64. $reporterId = $this->createReporter('rep-audit');
  65. $resp = $this->request(
  66. 'POST',
  67. '/api/v1/admin/tokens',
  68. ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
  69. (string) json_encode(['kind' => 'reporter', 'reporter_id' => $reporterId]),
  70. );
  71. self::assertSame(201, $resp->getStatusCode());
  72. $body = $this->decode($resp);
  73. $rawToken = (string) $body['raw_token'];
  74. $row = $this->db->fetchAssociative(
  75. "SELECT details_json FROM audit_log WHERE action = 'token.created' ORDER BY id DESC LIMIT 1"
  76. );
  77. self::assertIsArray($row);
  78. $json = (string) $row['details_json'];
  79. self::assertStringNotContainsString($rawToken, $json);
  80. // Prefix is allowed.
  81. $details = json_decode($json, true);
  82. self::assertIsArray($details);
  83. self::assertArrayHasKey('prefix', $details);
  84. self::assertArrayHasKey('kind', $details);
  85. }
  86. public function testFailedValidationDoesNotEmitAudit(): void
  87. {
  88. $admin = $this->createToken(TokenKind::Admin, Role::Admin);
  89. $resp = $this->request(
  90. 'POST',
  91. '/api/v1/admin/manual-blocks',
  92. ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
  93. (string) json_encode(['kind' => 'ip', 'ip' => 'not-an-ip']),
  94. );
  95. self::assertSame(400, $resp->getStatusCode());
  96. $count = (int) $this->db->fetchOne("SELECT COUNT(*) FROM audit_log WHERE action = 'manual_block.created'");
  97. self::assertSame(0, $count);
  98. }
  99. public function testCategoryCreateUpdateDeleteAudit(): void
  100. {
  101. $admin = $this->createToken(TokenKind::Admin, Role::Admin);
  102. $resp = $this->request(
  103. 'POST',
  104. '/api/v1/admin/categories',
  105. ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
  106. (string) json_encode([
  107. 'slug' => 'audit_test',
  108. 'name' => 'Audit Test',
  109. 'decay_function' => 'linear',
  110. 'decay_param' => 30.0,
  111. ]),
  112. );
  113. self::assertSame(201, $resp->getStatusCode());
  114. $created = $this->decode($resp);
  115. $id = (int) $created['id'];
  116. $resp = $this->request(
  117. 'PATCH',
  118. '/api/v1/admin/categories/' . $id,
  119. ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
  120. (string) json_encode(['name' => 'Audit Test Renamed']),
  121. );
  122. self::assertSame(200, $resp->getStatusCode());
  123. $resp = $this->request(
  124. 'DELETE',
  125. '/api/v1/admin/categories/' . $id,
  126. ['Authorization' => 'Bearer ' . $admin],
  127. );
  128. self::assertSame(204, $resp->getStatusCode());
  129. $rows = $this->db->fetchAllAssociative(
  130. "SELECT action FROM audit_log WHERE target_type = 'category' AND target_id = ? ORDER BY id ASC",
  131. [(string) $id],
  132. );
  133. $actions = array_map(static fn ($r) => $r['action'], $rows);
  134. self::assertSame(['category.created', 'category.updated', 'category.deleted'], $actions);
  135. }
  136. }