fix(search): move pagination clamp to controller (fixes cleanup regression)

Phase 5 re-validation caught a functional regression: the V-10 clamp in
SearchService.SearchAsync also capped CleanupService's internal target resolution
(pageSize 10000 -> 200), silently limiting bulk cleanup-by-query to 200 emails.

The clamp belongs at the user-facing trust boundary, not the shared service: move
MaxPageSize (200) enforcement into SearchController (both the POST body and GET query
paths). Internal callers of ISearchService now request large pages unhindered, while
user requests are still bounded. Adds a regression test proving SearchService returns
a 250-row page uncapped. No security regressions per Phase 5. All 39 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-01 00:29:45 +02:00
parent c3bca051ea
commit 1e15b84dce
3 changed files with 68 additions and 11 deletions
@@ -7,19 +7,31 @@ namespace InboxIntel.Api.Controllers;
public class SearchController : ApiControllerBase
{
/// <summary>Hard upper bound on a user-requested page (V-10: DoS via huge pageSize).
/// Enforced HERE, at the user-facing trust boundary, so internal callers of
/// ISearchService (e.g. cleanup target resolution) can still request large pages.</summary>
private const int MaxPageSize = 200;
private readonly ISearchService _search;
public SearchController(ISearchService search) => _search = search;
/// <summary>Structured search via JSON body.</summary>
[HttpPost]
public async Task<IActionResult> Search([FromBody] SearchRequestDto request, CancellationToken ct)
=> Ok(await _search.SearchAsync(UserId, request, ct));
{
var clamped = request with
{
Page = Math.Max(1, request.Page),
PageSize = Math.Clamp(request.PageSize, 1, MaxPageSize)
};
return Ok(await _search.SearchAsync(UserId, clamped, ct));
}
/// <summary>Gmail-like query string search, e.g. ?q=from:github.com is:unread.</summary>
[HttpGet]
public async Task<IActionResult> Query([FromQuery] string? q, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default)
{
var parsed = GmailQueryParser.Parse(q, page, pageSize);
var parsed = GmailQueryParser.Parse(q, Math.Max(1, page), Math.Clamp(pageSize, 1, MaxPageSize));
return Ok(await _search.SearchAsync(UserId, parsed, ct));
}
}