diff --git a/JsonApiToolkit.Tests/Integration/StrictPaginationIntegrationTests.cs b/JsonApiToolkit.Tests/Integration/StrictPaginationIntegrationTests.cs new file mode 100644 index 0000000..503064d --- /dev/null +++ b/JsonApiToolkit.Tests/Integration/StrictPaginationIntegrationTests.cs @@ -0,0 +1,173 @@ +using System.Net; +using System.Text.Json; +using JsonApiToolkit.Extensions; +using JsonApiToolkit.Models.Documents; +using JsonApiToolkit.Models.Errors; +using JsonApiToolkit.Models.Resources; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace JsonApiToolkit.Tests.Integration; + +/// +/// Integration tests for runtime strict-pagination behavior (page > totalPages → 404). +/// Re-uses the QueryTestArticle/QueryTestDbContext fixtures from JsonApiQueryAsyncTests. +/// +public class StrictPaginationIntegrationTests : IDisposable +{ + private readonly IHost _host; + private readonly HttpClient _client; + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + public StrictPaginationIntegrationTests() + { + var databaseName = $"StrictPaginationTestDb_{Guid.NewGuid()}"; + + _host = new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddDbContext(options => + options.UseInMemoryDatabase(databaseName) + ); + services.AddControllers(); + services.AddJsonApiToolkit(options => + { + options.StrictPagination = true; + }); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); + + using var scope = app.ApplicationServices.CreateScope(); + var context = + scope.ServiceProvider.GetRequiredService(); + SeedFiveArticles(context); + }); + }) + .Build(); + + _host.Start(); + _client = _host.GetTestClient(); + } + + private static void SeedFiveArticles(QueryTestDbContext context) + { + for (int i = 1; i <= 5; i++) + { + context.Articles.Add( + new QueryTestArticle + { + Id = i, + Title = $"Article {i}", + Content = $"Content {i}", + CreatedAt = new DateTime(2024, 1, i), + IsPublished = true, + ViewCount = i * 10, + } + ); + } + context.SaveChanges(); + } + + [Fact] + public async Task PageBeyondTotal_Returns404Async() + { + // 5 articles, page size 2 → 3 total pages. Page 100 must 404. + var response = await _client.GetAsync("/api/articles?page[number]=100&page[size]=2"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PageBeyondTotal_ErrorBodyHasMetaAsync() + { + var response = await _client.GetAsync("/api/articles?page[number]=10&page[size]=2"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var doc = JsonSerializer.Deserialize(content, _jsonOptions); + + Assert.NotNull(doc?.Errors); + var error = Assert.Single(doc.Errors); + Assert.Equal("404", error.Status); + Assert.Equal(JsonApiErrorCodes.InvalidPageNumber, error.Code); + Assert.Equal("page[number]", error.Source?.Parameter); + Assert.NotNull(error.Meta); + Assert.Equal(10, GetIntFromMeta(error.Meta, "value")); + Assert.Equal(3, GetIntFromMeta(error.Meta, "totalPages")); + Assert.Equal(5, GetIntFromMeta(error.Meta, "totalResources")); + } + + [Fact] + public async Task LastPage_Returns200Async() + { + // Exactly the last page must succeed (boundary check: > not >=). + var response = await _client.GetAsync("/api/articles?page[number]=3&page[size]=2&sort=id"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var doc = JsonSerializer.Deserialize>( + content, + _jsonOptions + ); + + Assert.NotNull(doc?.Data); + Assert.Single(doc.Data); + Assert.Equal("5", doc.Data.First().Id); + } + + [Fact] + public async Task EmptyResultWithPaging_DoesNotReturn404Async() + { + // Filter that matches no rows. With totalCount=0, strict mode must not 404 page=2 — + // there are no pages to be wrong about. + var response = await _client.GetAsync( + "/api/articles?filter[title]=NoSuchArticle&page[number]=2&page[size]=10" + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var doc = JsonSerializer.Deserialize>( + content, + _jsonOptions + ); + + Assert.NotNull(doc?.Data); + Assert.Empty(doc.Data); + } + + private static int GetIntFromMeta(Dictionary meta, string key) + { + Assert.True(meta.TryGetValue(key, out var raw), $"Missing meta key '{key}'"); + return raw switch + { + JsonElement e => e.GetInt32(), + int i => i, + long l => (int)l, + _ => Convert.ToInt32(raw), + }; + } + + public void Dispose() + { + _client.Dispose(); + _host.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/JsonApiToolkit/Controllers/JsonApiController.cs b/JsonApiToolkit/Controllers/JsonApiController.cs index 9ef5f27..76a5671 100644 --- a/JsonApiToolkit/Controllers/JsonApiController.cs +++ b/JsonApiToolkit/Controllers/JsonApiController.cs @@ -206,6 +206,26 @@ string resourceType int totalCount = await filteredQuery.CountAsync().ConfigureAwait(false); LogCountResults(parameters, totalCount); + if (Options.StrictPagination && parameters.Pagination != null && totalCount > 0) + { + int totalPages = (int)Math.Ceiling(totalCount / (double)parameters.Pagination.Size); + if (parameters.Pagination.Number > totalPages) + { + throw new JsonApiNotFoundException( + $"Page {parameters.Pagination.Number} does not exist. " + + $"This collection has {totalPages} page(s). Request a page between 1 and {totalPages}.", + JsonApiErrorCodes.InvalidPageNumber, + new ErrorSource { Parameter = "page[number]" }, + new Dictionary + { + ["value"] = parameters.Pagination.Number, + ["totalPages"] = totalPages, + ["totalResources"] = totalCount, + } + ); + } + } + if (parameters.Pagination != null) filteredQuery = filteredQuery.ApplyPagination(parameters.Pagination, totalCount); diff --git a/docs/docs/security.md b/docs/docs/security.md index 9d2e5f9..69b7cac 100644 --- a/docs/docs/security.md +++ b/docs/docs/security.md @@ -183,15 +183,20 @@ builder.Services.AddJsonApiToolkit(options => { ### Behavior -When `StrictPagination` is enabled, the following values are rejected with 400 Bad Request: +When `StrictPagination` is enabled, the following values are rejected with 400 Bad Request at parse time: - `page[number]` less than 1 (e.g., `page[number]=0` or `page[number]=-5`) - `page[size]` less than 1 (e.g., `page[size]=0` or `page[size]=-10`) - `page[size]` exceeding `MaxPageSize` (e.g., `page[size]=200` when `MaxPageSize=100`) +After the count is computed, the following also returns **404 Not Found**: + +- `page[number]` greater than the total page count (e.g., `page[number]=100` for a result set with 3 pages). Returns no 404 when the result set is empty (no pages exist). + When disabled (default), these values are silently clamped: - `page[number]` less than 1 becomes 1 +- `page[number]` greater than total pages becomes the last page - `page[size]` exceeding `MaxPageSize` becomes `MaxPageSize` ### Error Response diff --git a/docs/docs/upgrade-guide.md b/docs/docs/upgrade-guide.md index 8ae6b45..5caf543 100644 --- a/docs/docs/upgrade-guide.md +++ b/docs/docs/upgrade-guide.md @@ -2,7 +2,38 @@ This document tracks all breaking changes, new features, and migration steps for each version of JsonApiToolkit. -**Current Version:** 2.0.0 +**Current Version:** 2.1.0 + +--- + +## v2.1.0 - Strict Pagination (runtime) + +**New Behavior:** +`StrictPagination` now also rejects requests for a page that does not exist (page number greater than the total number of pages). Previously these requests were clamped to the last page. + +When `StrictPagination = true` and the requested `page[number]` exceeds the total page count for the resolved query, the controller returns **404 Not Found** with a JSON:API error document: + +```json +{ + "errors": [{ + "status": "404", + "code": "INVALID_PAGE_NUMBER", + "detail": "Page 10 does not exist. This collection has 3 page(s). Request a page between 1 and 3.", + "source": { "parameter": "page[number]" }, + "meta": { + "value": 10, + "totalPages": 3, + "totalResources": 5 + } + }] +} +``` + +**Edge case:** When a query returns zero resources (for example, a filter that matches nothing), no 404 is raised regardless of the requested page number. There are no pages to be wrong about. + +**Default behavior (`StrictPagination = false`) is unchanged**: out-of-range page numbers are clamped to the last page. + +**Breaking Changes:** None. Behavior change is opt-in via `StrictPagination`. Parse-time `StrictPagination` errors (invalid page number, invalid page size, page size exceeds maximum) introduced earlier are unchanged. ---