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()); } public function testOversizedRequestBodyIsRejectedWith413(): void { // SEC_REVIEW F69: a Reporter token can no longer POST a // multi-megabyte body that gets `getContents()`-ed into // memory by Slim's BodyParsingMiddleware. The global // RequestBodySizeLimitMiddleware (256 KiB cap) returns 413 // BEFORE the body is read. $reporterId = $this->createReporter('web-oversize'); $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId); // 512 KiB of plaintext, well past the 256 KiB cap. $bigBody = json_encode([ 'ip' => '203.0.113.42', 'category' => 'spam', 'metadata' => ['blob' => str_repeat('A', 512 * 1024)], ]) ?: ''; $resp = $this->request( 'POST', '/api/v1/report', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], $bigBody, ); self::assertSame(413, $resp->getStatusCode()); self::assertSame('payload_too_large', $this->decode($resp)['error']); } public function testDeeplyNestedJsonDoesNotBlowTheStack(): void { // SEC_REVIEW F69: bounded `json_decode` depth (32). A // 100-level nested object would otherwise hit PHP's default // 512-deep recursion limit and risk a stack issue on top of // wasting CPU. Past the cap, json_decode throws — // `ReportController::jsonBody` catches and treats as empty, // so the request continues and fails the regular `ip // required` validation. $reporterId = $this->createReporter('web-deepjson'); $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId); // Build a 100-deep nested object. $payload = '{}'; for ($i = 0; $i < 100; $i++) { $payload = '{"x":' . $payload . '}'; } $resp = $this->request( 'POST', '/api/v1/report', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], $payload, ); // Slim's BodyParsingMiddleware uses its own json_decode // (depth 512), so it succeeds — but then the controller's // validation runs and rejects "ip required" with 400. The // important guarantee is "no 500, no exception leaked"; the // exact response code isn't the point. self::assertContains($resp->getStatusCode(), [400, 413]); } }