createReporter('web-prod'); $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId); $resp = $this->request( 'POST', '/api/v1/report', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['ip' => '203.0.113.42', 'category' => 'brute_force']) ?: null, ); self::assertSame(202, $resp->getStatusCode()); $body = $this->decode($resp); self::assertArrayHasKey('report_id', $body); self::assertSame('203.0.113.42', $body['ip']); self::assertArrayHasKey('received_at', $body); // ip_scores must have a row > 0. $score = $this->db->fetchOne( "SELECT score FROM ip_scores WHERE ip_text = '203.0.113.42'" ); self::assertNotFalse($score); self::assertGreaterThan(0.0, (float) $score); } public function testManyReportsAccumulateMonotonically(): void { $reporterId = $this->createReporter('web-many'); $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId); for ($i = 0; $i < 5; $i++) { $this->request( 'POST', '/api/v1/report', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['ip' => '198.51.100.7', 'category' => 'scanner']) ?: null, ); } $count = (int) $this->db->fetchOne( "SELECT COUNT(*) FROM reports WHERE ip_text = '198.51.100.7'" ); self::assertSame(5, $count); $score = (float) $this->db->fetchOne( "SELECT score FROM ip_scores WHERE ip_text = '198.51.100.7'" ); // 5 fresh reports × weight 1.0 × decay~1.0 should be ~5.0. self::assertEqualsWithDelta(5.0, $score, 0.05); } public function testWrongKindTokenRejected(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $resp = $this->request( 'POST', '/api/v1/report', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['ip' => '1.2.3.4', 'category' => 'spam']) ?: null, ); self::assertSame(401, $resp->getStatusCode()); } public function testInvalidIpReturns400(): void { $reporterId = $this->createReporter('web-bad-ip'); $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId); $resp = $this->request( 'POST', '/api/v1/report', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['ip' => 'not-an-ip', 'category' => 'spam']) ?: null, ); self::assertSame(400, $resp->getStatusCode()); $body = $this->decode($resp); self::assertSame('validation_failed', $body['error']); self::assertArrayHasKey('ip', $body['details']); } public function testUnknownCategoryReturns400(): void { $reporterId = $this->createReporter('web-bad-cat'); $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId); $resp = $this->request( 'POST', '/api/v1/report', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['ip' => '1.2.3.4', 'category' => 'no-such']) ?: null, ); self::assertSame(400, $resp->getStatusCode()); self::assertArrayHasKey('category', $this->decode($resp)['details']); } public function testMetadataMustBeObject(): void { $reporterId = $this->createReporter('web-bad-meta'); $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId); $resp = $this->request( 'POST', '/api/v1/report', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['ip' => '1.2.3.4', 'category' => 'spam', 'metadata' => [1, 2, 3]]) ?: null, ); self::assertSame(400, $resp->getStatusCode()); } public function testMetadataExceedingLimitRejected(): void { $reporterId = $this->createReporter('web-big-meta'); $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId); $big = ['blob' => str_repeat('A', 5000)]; $resp = $this->request( 'POST', '/api/v1/report', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['ip' => '1.2.3.4', 'category' => 'spam', 'metadata' => $big]) ?: null, ); self::assertSame(400, $resp->getStatusCode()); } public function testInactiveReporterTokenRejected(): void { $reporterId = $this->createReporter('web-disabled'); $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId); $this->db->update('reporters', ['is_active' => 0], ['id' => $reporterId]); $resp = $this->request( 'POST', '/api/v1/report', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['ip' => '1.2.3.4', 'category' => 'spam']) ?: null, ); self::assertSame(401, $resp->getStatusCode()); } }