AuditLogControllerTest.php 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Admin;
  4. use App\Domain\Auth\Role;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Tests\Integration\Support\AppTestCase;
  7. /**
  8. * `/api/v1/admin/audit-log` end-to-end. Seeds rows directly so the
  9. * filter coverage doesn't depend on emission timing.
  10. */
  11. final class AuditLogControllerTest extends AppTestCase
  12. {
  13. public function testListEmpty(): void
  14. {
  15. $token = $this->createToken(TokenKind::Admin, Role::Viewer);
  16. $resp = $this->request('GET', '/api/v1/admin/audit-log', ['Authorization' => 'Bearer ' . $token]);
  17. self::assertSame(200, $resp->getStatusCode());
  18. $body = $this->decode($resp);
  19. self::assertSame([], $body['items']);
  20. self::assertSame(0, $body['total']);
  21. }
  22. public function testListWithFilters(): void
  23. {
  24. $now = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
  25. $this->seedAudit('user', '7', 'manual_block.created', 'manual_block', '1', '{"ip":"1.1.1.1"}', $now);
  26. $this->seedAudit('admin-token', '3', 'category.created', 'category', '2', '{"slug":"x"}', $now);
  27. $this->seedAudit('user', '7', 'allowlist.created', 'allowlist', '5', '{"ip":"2.2.2.2"}', $now);
  28. $token = $this->createToken(TokenKind::Admin, Role::Viewer);
  29. // Filter by actor_kind=user
  30. $resp = $this->request('GET', '/api/v1/admin/audit-log?actor_kind=user', ['Authorization' => 'Bearer ' . $token]);
  31. $body = $this->decode($resp);
  32. self::assertSame(2, $body['total']);
  33. foreach ($body['items'] as $item) {
  34. self::assertSame('user', $item['actor_kind']);
  35. }
  36. // Filter by action
  37. $resp = $this->request('GET', '/api/v1/admin/audit-log?action=category.created', ['Authorization' => 'Bearer ' . $token]);
  38. $body = $this->decode($resp);
  39. self::assertSame(1, $body['total']);
  40. self::assertSame('category.created', $body['items'][0]['action']);
  41. self::assertSame(['slug' => 'x'], $body['items'][0]['details']);
  42. // Filter by entity_type=manual_block
  43. $resp = $this->request('GET', '/api/v1/admin/audit-log?entity_type=manual_block', ['Authorization' => 'Bearer ' . $token]);
  44. $body = $this->decode($resp);
  45. self::assertSame(1, $body['total']);
  46. self::assertSame('manual_block', $body['items'][0]['entity_type']);
  47. }
  48. public function testSubjectFilterUnionsActorAndTarget(): void
  49. {
  50. $now = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
  51. // (1) admin updated reporter #5 — target=reporter, actor=user
  52. $this->seedAudit('user', '1', 'reporter.updated', 'reporter', '5', '{}', $now);
  53. // (2) reporter #5 emitted a report.received — actor=reporter, target=report
  54. $this->seedAudit('reporter', '5', 'report.received', 'report', '99', '{}', $now);
  55. // (3) different reporter — must NOT appear in subject_kind=reporter,subject_id=5
  56. $this->seedAudit('reporter', '6', 'report.received', 'report', '100', '{}', $now);
  57. // (4) unrelated row — must NOT appear
  58. $this->seedAudit('user', '1', 'manual_block.created', 'manual_block', '7', '{}', $now);
  59. $token = $this->createToken(TokenKind::Admin, Role::Viewer);
  60. $resp = $this->request(
  61. 'GET',
  62. '/api/v1/admin/audit-log?subject_kind=reporter&subject_id=5',
  63. ['Authorization' => 'Bearer ' . $token],
  64. );
  65. $body = $this->decode($resp);
  66. self::assertSame(2, $body['total']);
  67. $actions = array_map(static fn (array $r): string => $r['action'], $body['items']);
  68. self::assertContains('reporter.updated', $actions);
  69. self::assertContains('report.received', $actions);
  70. self::assertNotContains('manual_block.created', $actions);
  71. }
  72. public function testSubjectKindWithoutIdReturns400(): void
  73. {
  74. $token = $this->createToken(TokenKind::Admin, Role::Viewer);
  75. $resp = $this->request(
  76. 'GET',
  77. '/api/v1/admin/audit-log?subject_kind=reporter',
  78. ['Authorization' => 'Bearer ' . $token],
  79. );
  80. self::assertSame(400, $resp->getStatusCode());
  81. self::assertArrayHasKey('subject', $this->decode($resp)['details']);
  82. }
  83. public function testInvalidActorKindReturns400(): void
  84. {
  85. $token = $this->createToken(TokenKind::Admin, Role::Viewer);
  86. $resp = $this->request('GET', '/api/v1/admin/audit-log?actor_kind=potato', ['Authorization' => 'Bearer ' . $token]);
  87. self::assertSame(400, $resp->getStatusCode());
  88. }
  89. public function testRequiresViewer(): void
  90. {
  91. $resp = $this->request('GET', '/api/v1/admin/audit-log');
  92. self::assertSame(401, $resp->getStatusCode());
  93. }
  94. private function seedAudit(string $kind, ?string $actorId, string $action, string $type, string $id, string $details, string $when): void
  95. {
  96. $this->db->insert('audit_log', [
  97. 'actor_kind' => $kind,
  98. 'actor_id' => $actorId,
  99. 'action' => $action,
  100. 'target_type' => $type,
  101. 'target_id' => $id,
  102. 'details_json' => $details,
  103. 'ip_address' => null,
  104. 'created_at' => $when,
  105. ]);
  106. }
  107. }