createToken(TokenKind::Admin, role: Role::Operator); $response = $this->request( 'POST', '/api/v1/admin/reporters', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['name' => 'web-prod']) ?: null, ); self::assertSame(403, $response->getStatusCode()); } public function testAdminCanCreateAndFetchReporter(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $created = $this->request( 'POST', '/api/v1/admin/reporters', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode([ 'name' => 'web-prod-01', 'description' => 'first webserver', 'trust_weight' => 1.5, ]) ?: null, ); self::assertSame(201, $created->getStatusCode()); $body = $this->decode($created); self::assertSame('web-prod-01', $body['name']); self::assertSame(1.5, $body['trust_weight']); self::assertTrue($body['is_active']); $id = (int) $body['id']; $detail = $this->request('GET', "/api/v1/admin/reporters/{$id}", [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $detail->getStatusCode()); self::assertSame('web-prod-01', $this->decode($detail)['name']); } public function testCreateRejectsOutOfRangeTrustWeight(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $response = $this->request( 'POST', '/api/v1/admin/reporters', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['name' => 'bad', 'trust_weight' => 5.0]) ?: null, ); self::assertSame(400, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('validation_failed', $body['error']); self::assertArrayHasKey('trust_weight', $body['details']); } public function testDuplicateNameRejected(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $this->request( 'POST', '/api/v1/admin/reporters', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['name' => 'dup']) ?: null, ); $second = $this->request( 'POST', '/api/v1/admin/reporters', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['name' => 'dup']) ?: null, ); self::assertSame(400, $second->getStatusCode()); } public function testPatchUpdatesFields(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $reporterId = $this->createReporter('web-edit'); $patch = $this->request( 'PATCH', "/api/v1/admin/reporters/{$reporterId}", ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['trust_weight' => 0.25, 'is_active' => false]) ?: null, ); self::assertSame(200, $patch->getStatusCode()); $body = $this->decode($patch); self::assertSame(0.25, $body['trust_weight']); self::assertFalse($body['is_active']); } public function testPatchTogglesAuditEnabled(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $reporterId = $this->createReporter('web-audit-toggle'); // Default is true on create. $detail = $this->request('GET', "/api/v1/admin/reporters/{$reporterId}", [ 'Authorization' => 'Bearer ' . $token, ]); self::assertTrue($this->decode($detail)['audit_enabled']); $patch = $this->request( 'PATCH', "/api/v1/admin/reporters/{$reporterId}", ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['audit_enabled' => false]) ?: null, ); self::assertSame(200, $patch->getStatusCode()); self::assertFalse($this->decode($patch)['audit_enabled']); } public function testAuditEnabledToggleEmitsDedicatedAuditRow(): void { // SEC_REVIEW F41: an admin flipping `audit_enabled` for a reporter // must leave a flat alertable trail SOC tooling can match on with // `action = 'reporter.audit_toggled'` — without walking into the // details_json `changes` blob of the standard `reporter.updated` row. $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $reporterId = $this->createReporter('web-audit-toggle-audit'); $this->request( 'PATCH', "/api/v1/admin/reporters/{$reporterId}", ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['audit_enabled' => false]) ?: null, ); $rows = $this->db->fetchAllAssociative( "SELECT action, details_json FROM audit_log WHERE target_type = 'reporter' AND target_id = ? ORDER BY id", [(string) $reporterId], ); $actions = array_column($rows, 'action'); // Both signals fire: the existing standard row plus the dedicated toggle. self::assertContains('reporter.updated', $actions); self::assertContains('reporter.audit_toggled', $actions); $toggleRow = null; foreach ($rows as $row) { if ($row['action'] === 'reporter.audit_toggled') { $toggleRow = $row; break; } } self::assertNotNull($toggleRow); $meta = json_decode((string) $toggleRow['details_json'], true); self::assertSame(true, $meta['from'] ?? null); self::assertSame(false, $meta['to'] ?? null); } public function testAuditEnabledNoOpDoesNotEmitDedicatedRow(): void { // PATCHing `audit_enabled` to its current value (no-op) must NOT // fire the toggle signal — SOC alerts would otherwise see noise. $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $reporterId = $this->createReporter('web-audit-noop'); // Default is true; PATCH it to true (no-op). $this->request( 'PATCH', "/api/v1/admin/reporters/{$reporterId}", ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['audit_enabled' => true]) ?: null, ); $actions = $this->db->fetchFirstColumn( "SELECT action FROM audit_log WHERE target_type = 'reporter' AND target_id = ?", [(string) $reporterId], ); self::assertNotContains('reporter.audit_toggled', $actions); } public function testDeleteWithoutReportsSoftDeletes(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $reporterId = $this->createReporter('web-disposable'); $delete = $this->request('DELETE', "/api/v1/admin/reporters/{$reporterId}", [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(204, $delete->getStatusCode()); $detail = $this->request('GET', "/api/v1/admin/reporters/{$reporterId}", [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $detail->getStatusCode()); self::assertFalse($this->decode($detail)['is_active']); } public function testDeleteWithReportsReturns409(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $reporterId = $this->createReporter('web-with-reports'); $categoryId = (int) $this->db->fetchOne( 'SELECT id FROM categories WHERE slug = :slug', ['slug' => 'brute_force'] ); $this->db->insert('reports', [ 'category_id' => $categoryId, 'reporter_id' => $reporterId, 'ip_bin' => str_repeat("\0", 12) . "\xff\xff\x01\x02", 'ip_text' => '0.0.0.1', 'weight_at_report' => '1.00', 'received_at' => '2026-01-01 00:00:00', ]); $delete = $this->request('DELETE', "/api/v1/admin/reporters/{$reporterId}", [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(409, $delete->getStatusCode()); } }