AuditLogControllerTest.php 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  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. public function testOversizedFilterRejected(): void
  95. {
  96. // SEC_REVIEW F31: free-form filter strings are bounded at 128 chars.
  97. $token = $this->createToken(TokenKind::Admin, Role::Viewer);
  98. $long = str_repeat('a', 200);
  99. $resp = $this->request(
  100. 'GET',
  101. '/api/v1/admin/audit-log?action=' . $long,
  102. ['Authorization' => 'Bearer ' . $token],
  103. );
  104. self::assertSame(400, $resp->getStatusCode());
  105. self::assertArrayHasKey('action', $this->decode($resp)['details']);
  106. $resp = $this->request(
  107. 'GET',
  108. '/api/v1/admin/audit-log?entity_type=' . $long,
  109. ['Authorization' => 'Bearer ' . $token],
  110. );
  111. self::assertSame(400, $resp->getStatusCode());
  112. self::assertArrayHasKey('entity_type', $this->decode($resp)['details']);
  113. $resp = $this->request(
  114. 'GET',
  115. '/api/v1/admin/audit-log?entity_id=' . $long,
  116. ['Authorization' => 'Bearer ' . $token],
  117. );
  118. self::assertSame(400, $resp->getStatusCode());
  119. self::assertArrayHasKey('entity_id', $this->decode($resp)['details']);
  120. $resp = $this->request(
  121. 'GET',
  122. '/api/v1/admin/audit-log?subject_kind=' . $long . '&subject_id=5',
  123. ['Authorization' => 'Bearer ' . $token],
  124. );
  125. self::assertSame(400, $resp->getStatusCode());
  126. self::assertArrayHasKey('subject', $this->decode($resp)['details']);
  127. }
  128. public function testDeepOffsetRejected(): void
  129. {
  130. // SEC_REVIEW F31: pagination is capped at 10 000 offset rows so a
  131. // Viewer can't force `LIMIT 200 OFFSET huge` deep scans.
  132. $token = $this->createToken(TokenKind::Admin, Role::Viewer);
  133. $resp = $this->request(
  134. 'GET',
  135. '/api/v1/admin/audit-log?page=999999&page_size=200',
  136. ['Authorization' => 'Bearer ' . $token],
  137. );
  138. self::assertSame(400, $resp->getStatusCode());
  139. self::assertArrayHasKey('page', $this->decode($resp)['details']);
  140. }
  141. public function testDeepOffsetAtBoundaryAccepted(): void
  142. {
  143. // Right at offset = MAX_OFFSET (10 000) is fine; one more page over
  144. // is rejected.
  145. $token = $this->createToken(TokenKind::Admin, Role::Viewer);
  146. // page=51, page_size=200 → offset=10000 (boundary, allowed)
  147. $resp = $this->request(
  148. 'GET',
  149. '/api/v1/admin/audit-log?page=51&page_size=200',
  150. ['Authorization' => 'Bearer ' . $token],
  151. );
  152. self::assertSame(200, $resp->getStatusCode());
  153. // page=52, page_size=200 → offset=10200 (over boundary, rejected)
  154. $resp = $this->request(
  155. 'GET',
  156. '/api/v1/admin/audit-log?page=52&page_size=200',
  157. ['Authorization' => 'Bearer ' . $token],
  158. );
  159. self::assertSame(400, $resp->getStatusCode());
  160. }
  161. private function seedAudit(string $kind, ?string $actorId, string $action, string $type, string $id, string $details, string $when): void
  162. {
  163. $this->db->insert('audit_log', [
  164. 'actor_kind' => $kind,
  165. 'actor_id' => $actorId,
  166. 'action' => $action,
  167. 'target_type' => $type,
  168. 'target_id' => $id,
  169. 'details_json' => $details,
  170. 'ip_address' => null,
  171. 'created_at' => $when,
  172. ]);
  173. }
  174. }