diff --git a/system/API/ResponseTrait.php b/system/API/ResponseTrait.php index 0449e91bf07c..22deafc5fdc7 100644 --- a/system/API/ResponseTrait.php +++ b/system/API/ResponseTrait.php @@ -416,15 +416,19 @@ protected function paginate(BaseBuilder|Model $resource, int $perPage = 20, ?str ]; } else { // Query Builder, we need to handle pagination manually - $offset = ($page - 1) * $perPage; - $total = (clone $resource)->countAllResults(); - $data = $resource->limit($perPage, $offset)->get()->getResultArray(); + // The page is clamped to the available range so that the offset + // cannot overflow the int type when a huge page is requested. + $total = (clone $resource)->countAllResults(); + $totalPages = (int) ceil($total / $perPage); + $page = max(1, min($page, max(1, $totalPages))); + $offset = ($page - 1) * $perPage; + $data = $resource->limit($perPage, $offset)->get()->getResultArray(); $meta = [ 'page' => $page, 'perPage' => $perPage, 'total' => $total, - 'totalPages' => (int) ceil($total / $perPage), + 'totalPages' => $totalPages, ]; } diff --git a/tests/system/API/ResponseTraitTest.php b/tests/system/API/ResponseTraitTest.php index f5d33491a655..22f2e0b2f6ef 100644 --- a/tests/system/API/ResponseTraitTest.php +++ b/tests/system/API/ResponseTraitTest.php @@ -799,6 +799,81 @@ public function testPaginateWithQueryBuilder(): void $this->assertSame(3, $responseBody['meta']['totalPages']); } + public function testPaginateWithQueryBuilderPageOverflow(): void + { + // Mock the database and builder + $db = $this->createStub(BaseConnection::class); + + $builder = $this->getMockBuilder(BaseBuilder::class) + ->setConstructorArgs(['test_table', $db]) + ->onlyMethods(['countAllResults', 'limit', 'get']) + ->getMock(); + + $result = $this->createMock(BaseResult::class); + + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ]; + + // Mock the query builder chain + $builder->method('countAllResults')->willReturn(50); + $builder->expects($this->once()) + ->method('limit') + ->with(20, 40) + ->willReturnSelf(); + $builder->method('get')->willReturn($result); + $result->method('getResultArray')->willReturn($data); + + $controller = $this->makeController('/api/items?page=' . PHP_INT_MAX); + $this->request->setGlobal('get', ['page' => (string) PHP_INT_MAX]); + + $this->invoke($controller, 'paginate', [$builder, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + $this->assertSame(3, $responseBody['meta']['page']); + $this->assertSame(20, $responseBody['meta']['perPage']); + $this->assertSame(50, $responseBody['meta']['total']); + $this->assertSame(3, $responseBody['meta']['totalPages']); + $this->assertSame($data, $responseBody['data']); + $this->assertStringContainsString('page=3', (string) $responseBody['links']['self']); + $this->assertNull($responseBody['links']['next']); + } + + public function testPaginateWithQueryBuilderZeroResultsPageOverflow(): void + { + // Mock the database and builder + $db = $this->createStub(BaseConnection::class); + + $builder = $this->getMockBuilder(BaseBuilder::class) + ->setConstructorArgs(['test_table', $db]) + ->onlyMethods(['countAllResults', 'limit', 'get']) + ->getMock(); + + $result = $this->createMock(BaseResult::class); + + // Mock the query builder chain + $builder->method('countAllResults')->willReturn(0); + $builder->expects($this->once()) + ->method('limit') + ->with(20, 0) + ->willReturnSelf(); + $builder->method('get')->willReturn($result); + $result->method('getResultArray')->willReturn([]); + + $controller = $this->makeController('/api/items?page=' . PHP_INT_MAX); + $this->request->setGlobal('get', ['page' => (string) PHP_INT_MAX]); + + $this->invoke($controller, 'paginate', [$builder, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + $this->assertSame(1, $responseBody['meta']['page']); + $this->assertSame(0, $responseBody['meta']['totalPages']); + $this->assertSame([], $responseBody['data']); + } + public function testPaginateWithCustomPerPage(): void { $data = [