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