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 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, ]); } }