| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Integration\Admin;
- use App\Domain\Auth\Role;
- use App\Domain\Auth\TokenKind;
- use App\Tests\Integration\Support\AppTestCase;
- /**
- * `/api/v1/admin/audit-log` end-to-end. Seeds rows directly so the
- * filter coverage doesn't depend on emission timing.
- */
- final class AuditLogControllerTest extends AppTestCase
- {
- public function testListEmpty(): void
- {
- $token = $this->createToken(TokenKind::Admin, Role::Viewer);
- $resp = $this->request('GET', '/api/v1/admin/audit-log', ['Authorization' => 'Bearer ' . $token]);
- self::assertSame(200, $resp->getStatusCode());
- $body = $this->decode($resp);
- self::assertSame([], $body['items']);
- self::assertSame(0, $body['total']);
- }
- public function testListWithFilters(): void
- {
- $now = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
- $this->seedAudit('user', '7', 'manual_block.created', 'manual_block', '1', '{"ip":"1.1.1.1"}', $now);
- $this->seedAudit('admin-token', '3', 'category.created', 'category', '2', '{"slug":"x"}', $now);
- $this->seedAudit('user', '7', 'allowlist.created', 'allowlist', '5', '{"ip":"2.2.2.2"}', $now);
- $token = $this->createToken(TokenKind::Admin, Role::Viewer);
- // Filter by actor_kind=user
- $resp = $this->request('GET', '/api/v1/admin/audit-log?actor_kind=user', ['Authorization' => 'Bearer ' . $token]);
- $body = $this->decode($resp);
- self::assertSame(2, $body['total']);
- foreach ($body['items'] as $item) {
- self::assertSame('user', $item['actor_kind']);
- }
- // Filter by action
- $resp = $this->request('GET', '/api/v1/admin/audit-log?action=category.created', ['Authorization' => 'Bearer ' . $token]);
- $body = $this->decode($resp);
- self::assertSame(1, $body['total']);
- self::assertSame('category.created', $body['items'][0]['action']);
- self::assertSame(['slug' => 'x'], $body['items'][0]['details']);
- // Filter by entity_type=manual_block
- $resp = $this->request('GET', '/api/v1/admin/audit-log?entity_type=manual_block', ['Authorization' => 'Bearer ' . $token]);
- $body = $this->decode($resp);
- self::assertSame(1, $body['total']);
- self::assertSame('manual_block', $body['items'][0]['entity_type']);
- }
- public function testSubjectFilterUnionsActorAndTarget(): void
- {
- $now = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
- // (1) admin updated reporter #5 — target=reporter, actor=user
- $this->seedAudit('user', '1', 'reporter.updated', 'reporter', '5', '{}', $now);
- // (2) reporter #5 emitted a report.received — actor=reporter, target=report
- $this->seedAudit('reporter', '5', 'report.received', 'report', '99', '{}', $now);
- // (3) different reporter — must NOT appear in subject_kind=reporter,subject_id=5
- $this->seedAudit('reporter', '6', 'report.received', 'report', '100', '{}', $now);
- // (4) unrelated row — must NOT appear
- $this->seedAudit('user', '1', 'manual_block.created', 'manual_block', '7', '{}', $now);
- $token = $this->createToken(TokenKind::Admin, Role::Viewer);
- $resp = $this->request(
- 'GET',
- '/api/v1/admin/audit-log?subject_kind=reporter&subject_id=5',
- ['Authorization' => 'Bearer ' . $token],
- );
- $body = $this->decode($resp);
- self::assertSame(2, $body['total']);
- $actions = array_map(static fn (array $r): string => $r['action'], $body['items']);
- self::assertContains('reporter.updated', $actions);
- self::assertContains('report.received', $actions);
- self::assertNotContains('manual_block.created', $actions);
- }
- public function testSubjectKindWithoutIdReturns400(): void
- {
- $token = $this->createToken(TokenKind::Admin, Role::Viewer);
- $resp = $this->request(
- 'GET',
- '/api/v1/admin/audit-log?subject_kind=reporter',
- ['Authorization' => 'Bearer ' . $token],
- );
- self::assertSame(400, $resp->getStatusCode());
- self::assertArrayHasKey('subject', $this->decode($resp)['details']);
- }
- public function testInvalidActorKindReturns400(): void
- {
- $token = $this->createToken(TokenKind::Admin, Role::Viewer);
- $resp = $this->request('GET', '/api/v1/admin/audit-log?actor_kind=potato', ['Authorization' => 'Bearer ' . $token]);
- self::assertSame(400, $resp->getStatusCode());
- }
- public function testRequiresViewer(): void
- {
- $resp = $this->request('GET', '/api/v1/admin/audit-log');
- self::assertSame(401, $resp->getStatusCode());
- }
- public function testOversizedFilterRejected(): void
- {
- // SEC_REVIEW F31: free-form filter strings are bounded at 128 chars.
- $token = $this->createToken(TokenKind::Admin, Role::Viewer);
- $long = str_repeat('a', 200);
- $resp = $this->request(
- 'GET',
- '/api/v1/admin/audit-log?action=' . $long,
- ['Authorization' => 'Bearer ' . $token],
- );
- self::assertSame(400, $resp->getStatusCode());
- self::assertArrayHasKey('action', $this->decode($resp)['details']);
- $resp = $this->request(
- 'GET',
- '/api/v1/admin/audit-log?entity_type=' . $long,
- ['Authorization' => 'Bearer ' . $token],
- );
- self::assertSame(400, $resp->getStatusCode());
- self::assertArrayHasKey('entity_type', $this->decode($resp)['details']);
- $resp = $this->request(
- 'GET',
- '/api/v1/admin/audit-log?entity_id=' . $long,
- ['Authorization' => 'Bearer ' . $token],
- );
- self::assertSame(400, $resp->getStatusCode());
- self::assertArrayHasKey('entity_id', $this->decode($resp)['details']);
- $resp = $this->request(
- 'GET',
- '/api/v1/admin/audit-log?subject_kind=' . $long . '&subject_id=5',
- ['Authorization' => 'Bearer ' . $token],
- );
- self::assertSame(400, $resp->getStatusCode());
- self::assertArrayHasKey('subject', $this->decode($resp)['details']);
- }
- public function testKindFilterCharsetGate(): void
- {
- // SEC_REVIEW F47: `entity_type` and `subject_kind` are matched
- // against `actor_kind` / `target_type` columns whose real values
- // are short snake/kebab identifiers. Reject anything outside
- // `^[a-z0-9][a-z0-9_-]*$` so wild bytes can't reach the
- // prepared statement (no SQLi today, but the zero-byte and
- // multi-megabyte cases waste planner work).
- $token = $this->createToken(TokenKind::Admin, Role::Viewer);
- // entity_type with uppercase / dot / space
- foreach (['Reporter', 'reporter.created', 'reporter manual', '-leading-dash', "report\nfaked"] as $bad) {
- $resp = $this->request(
- 'GET',
- '/api/v1/admin/audit-log?entity_type=' . rawurlencode($bad),
- ['Authorization' => 'Bearer ' . $token],
- );
- self::assertSame(400, $resp->getStatusCode(), "expected 400 for entity_type={$bad}");
- self::assertArrayHasKey('entity_type', $this->decode($resp)['details']);
- }
- // subject_kind needs the same gate (paired with subject_id).
- foreach (['Reporter', 'reporter.created', 'rep orter', '-leading-dash'] as $bad) {
- $resp = $this->request(
- 'GET',
- '/api/v1/admin/audit-log?subject_kind=' . rawurlencode($bad) . '&subject_id=5',
- ['Authorization' => 'Bearer ' . $token],
- );
- self::assertSame(400, $resp->getStatusCode(), "expected 400 for subject_kind={$bad}");
- self::assertArrayHasKey('subject', $this->decode($resp)['details']);
- }
- // Real kinds still pass through (smoke).
- foreach (['reporter', 'consumer', 'manual_block', 'admin-token', 'oidc_role_mapping'] as $good) {
- $resp = $this->request(
- 'GET',
- '/api/v1/admin/audit-log?entity_type=' . rawurlencode($good),
- ['Authorization' => 'Bearer ' . $token],
- );
- self::assertSame(200, $resp->getStatusCode(), "expected 200 for entity_type={$good}");
- }
- }
- public function testDeepOffsetRejected(): void
- {
- // SEC_REVIEW F31: pagination is capped at 10 000 offset rows so a
- // Viewer can't force `LIMIT 200 OFFSET huge` deep scans.
- $token = $this->createToken(TokenKind::Admin, Role::Viewer);
- $resp = $this->request(
- 'GET',
- '/api/v1/admin/audit-log?page=999999&page_size=200',
- ['Authorization' => 'Bearer ' . $token],
- );
- self::assertSame(400, $resp->getStatusCode());
- self::assertArrayHasKey('page', $this->decode($resp)['details']);
- }
- public function testDeepOffsetAtBoundaryAccepted(): void
- {
- // Right at offset = MAX_OFFSET (10 000) is fine; one more page over
- // is rejected.
- $token = $this->createToken(TokenKind::Admin, Role::Viewer);
- // page=51, page_size=200 → offset=10000 (boundary, allowed)
- $resp = $this->request(
- 'GET',
- '/api/v1/admin/audit-log?page=51&page_size=200',
- ['Authorization' => 'Bearer ' . $token],
- );
- self::assertSame(200, $resp->getStatusCode());
- // page=52, page_size=200 → offset=10200 (over boundary, rejected)
- $resp = $this->request(
- 'GET',
- '/api/v1/admin/audit-log?page=52&page_size=200',
- ['Authorization' => 'Bearer ' . $token],
- );
- self::assertSame(400, $resp->getStatusCode());
- }
- private function seedAudit(string $kind, ?string $actorId, string $action, string $type, string $id, string $details, string $when): void
- {
- $this->db->insert('audit_log', [
- 'actor_kind' => $kind,
- 'actor_id' => $actorId,
- 'action' => $action,
- 'target_type' => $type,
- 'target_id' => $id,
- 'details_json' => $details,
- 'ip_address' => null,
- 'created_at' => $when,
- ]);
- }
- }
|