| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Integration\Public;
- use App\Domain\Auth\Role;
- use App\Domain\Auth\TokenKind;
- use App\Tests\Integration\Support\AppTestCase;
- final class ReportControllerTest extends AppTestCase
- {
- public function testValidReportInsertedAndScoreUpdated(): void
- {
- $reporterId = $this->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]);
- }
- }
|