Report recovery pagination meta from the normalized limit

/api/recovery/points and /api/recovery/rollups clamp the requested page
size to [100 default, 500 max] in both the mock paginators and the store
paths, but the meta block was computed from the raw query value. A client
requesting limit=1000 with 1200 rollups was told totalPages=2 while the
server served 3 pages of 500, so iterating totalPages silently dropped
rollups; limit<=0 reported totalPages=1 at an effective limit of 100.

Normalize page and limit once at parse time, compute meta from the
normalized values, and echo the effective limit. Contract clause 34 in
api-contracts.md pins the obligation; storage-recovery and agent-lifecycle
record the boundary alignment and adjacency; recovery_handlers_test.go
pins above-max and non-positive limit meta.
This commit is contained in:
rcourtman
2026-08-24 09:18:36 +01:00
parent 29cbf73bba
commit 6686cdce2c
5 changed files with 186 additions and 37 deletions
@@ -2275,6 +2275,17 @@ NOT admit the standalone platform page on its own. The canonical predicate is
`unifiedresources.IsPulseAgentPlatformResource`; new agent surfaces MUST use it
rather than testing `type == "agent"` or counting the `agent` source.
### Recovery list pagination meta adjacency
Recovery list pagination-meta normalization in
`internal/api/recovery_handlers.go` (the clamped effective `limit` echoed in
`meta`, `totalPages` computed from that effective page size) is an
API-contracts and storage-recovery transport concern that lives under the
shared `internal/api/` extension boundary. No agent lifecycle surface consumes
the `/api/recovery/points` or `/api/recovery/rollups` list meta; lifecycle
code MUST NOT treat that pagination meta as an agent enrollment, admission, or
report-ingestion contract.
## Forbidden Paths
1. New install or update continuity behavior hidden only inside broad monitoring ownership.
@@ -3937,6 +3937,18 @@ the authoritative analysis outcome.
after a successful import-triggered reload request, returning a controlled
API outcome instead of panicking or leaving browser-visible state half
rewired.
34. Keep recovery list pagination meta honest on the shared `/api/recovery/*`
surface. `/api/recovery/points` and `/api/recovery/rollups` clamp the
requested page size to the canonical bounds (default 100, max 500) before
serving, in both the mock paginators and the store paths, so
`internal/api/recovery_handlers.go` must normalize `page` and `limit` once
at parse time and compute the response `meta` block (`page`, `limit`,
`totalPages`) from those normalized values. The meta must echo the
effective limit, never the raw query value, so a client iterating
`totalPages` is never told fewer pages than the server actually serves.
`internal/api/recovery_handlers_test.go` pins the above-max and
non-positive limit meta normalization in the same slice as any handler
change.
### Patrol attention transport
@@ -1845,6 +1845,18 @@ canonical resource aggregations. A TrueNAS host admits the TrueNAS page and not
the standalone page, and the shell MUST take that answer from the facet rather
than inferring it from an agent-source count in a runtime payload.
### Recovery list pagination meta
The `/api/recovery/points` and `/api/recovery/rollups` list transports in
`internal/api/recovery_handlers.go` clamp the requested page size to the
canonical bounds (default 100, max 500) and report their pagination meta
(`page`, `limit`, `totalPages`) from those normalized values. The store-side
clamps in `internal/recovery/store` (`normalizeLimit` / `normalizePage`) MUST
stay aligned with those handler-side bounds: a storage-side change to the
serving page size that does not move the shared handler bounds re-opens the
`totalPages` misreport that silently truncates rollup iteration for clients
walking the reported page count.
## Forbidden Paths
1. Reintroducing storage or recovery product logic as ad hoc dashboard-only summaries without a canonical page-surface owner
+36 -37
View File
@@ -191,8 +191,8 @@ func (h *RecoveryHandlers) HandleListPoints(w http.ResponseWriter, r *http.Reque
}
qs := r.URL.Query()
page := parseIntQuery(qs, "page", 1)
limit := parseIntQuery(qs, "limit", 100)
page := normalizeRecoveryPage(parseIntQuery(qs, "page", 1))
limit := normalizeRecoveryLimit(parseIntQuery(qs, "limit", recoveryDefaultPageLimit))
var from, to *time.Time
if t, err := parseRFC3339QueryTime(qs.Get("from")); err != nil {
@@ -271,11 +271,7 @@ func (h *RecoveryHandlers) HandleListPoints(w http.ResponseWriter, r *http.Reque
resp.Meta.Page = page
resp.Meta.Limit = limit
resp.Meta.Total = total
if limit <= 0 {
resp.Meta.TotalPages = 1
} else {
resp.Meta.TotalPages = (total + limit - 1) / limit
}
resp.Meta.TotalPages = (total + limit - 1) / limit
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to serialize recovery points response")
@@ -583,22 +579,39 @@ func getDisplayItemType(d *recovery.RecoveryPointDisplay) string {
return recovery.NormalizeRecoveryItemType(d.SubjectType)
}
// Recovery list pagination bounds. These mirror the store-side clamps in
// internal/recovery/store (normalizeLimit/normalizePage); the handler meta must
// be computed from the same normalized values the serving paths use, or
// totalPages misreports the real page count.
const (
recoveryDefaultPageLimit = 100
recoveryMaxPageLimit = 500
)
func normalizeRecoveryPage(page int) int {
if page <= 0 {
return 1
}
return page
}
func normalizeRecoveryLimit(limit int) int {
if limit <= 0 {
return recoveryDefaultPageLimit
}
if limit > recoveryMaxPageLimit {
return recoveryMaxPageLimit
}
return limit
}
func paginateRecoveryPoints(filtered []recovery.RecoveryPoint, page int, limit int) []recovery.RecoveryPoint {
if len(filtered) == 0 {
return []recovery.RecoveryPoint{}
}
normalizedLimit := limit
if normalizedLimit <= 0 {
normalizedLimit = 100
}
if normalizedLimit > 500 {
normalizedLimit = 500
}
normalizedPage := page
if normalizedPage <= 0 {
normalizedPage = 1
}
normalizedLimit := normalizeRecoveryLimit(limit)
normalizedPage := normalizeRecoveryPage(page)
offset := (normalizedPage - 1) * normalizedLimit
if offset >= len(filtered) {
@@ -618,8 +631,8 @@ func (h *RecoveryHandlers) HandleListRollups(w http.ResponseWriter, r *http.Requ
}
qs := r.URL.Query()
page := parseIntQuery(qs, "page", 1)
limit := parseIntQuery(qs, "limit", 100)
page := normalizeRecoveryPage(parseIntQuery(qs, "page", 1))
limit := normalizeRecoveryLimit(parseIntQuery(qs, "limit", recoveryDefaultPageLimit))
var from, to *time.Time
if t, err := parseRFC3339QueryTime(qs.Get("from")); err != nil {
@@ -687,12 +700,7 @@ func (h *RecoveryHandlers) HandleListRollups(w http.ResponseWriter, r *http.Requ
"page": page,
"limit": limit,
"total": total,
"totalPages": 0,
}
if limit <= 0 {
meta["totalPages"] = 1
} else {
meta["totalPages"] = (total + limit - 1) / limit
"totalPages": (total + limit - 1) / limit,
}
payloadRollups := make([]recoveryRollupPayload, 0, len(rollups))
@@ -1094,17 +1102,8 @@ func paginateRecoveryRollups(filtered []recovery.ProtectionRollup, page int, lim
return []recovery.ProtectionRollup{}
}
normalizedLimit := limit
if normalizedLimit <= 0 {
normalizedLimit = 100
}
if normalizedLimit > 500 {
normalizedLimit = 500
}
normalizedPage := page
if normalizedPage <= 0 {
normalizedPage = 1
}
normalizedLimit := normalizeRecoveryLimit(limit)
normalizedPage := normalizeRecoveryPage(page)
offset := (normalizedPage - 1) * normalizedLimit
if offset >= len(filtered) {
+115
View File
@@ -208,6 +208,121 @@ func TestHandleListRollupsExposeCanonicalPlatformsPayload(t *testing.T) {
}
}
func TestNormalizeRecoveryLimit(t *testing.T) {
t.Parallel()
tests := []struct {
name string
limit int
want int
}{
{name: "clamps above max to 500", limit: 1000, want: 500},
{name: "zero falls back to default 100", limit: 0, want: 100},
{name: "negative falls back to default 100", limit: -5, want: 100},
{name: "in-range value passes through", limit: 250, want: 250},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := normalizeRecoveryLimit(tc.limit); got != tc.want {
t.Fatalf("normalizeRecoveryLimit(%d) = %d, want %d", tc.limit, got, tc.want)
}
})
}
// The misreport this pins: 1200 rollups at requested limit=1000 are served
// as 3 pages of 500, so meta must say 3, not ceil(1200/1000)=2.
limit := normalizeRecoveryLimit(1000)
if got := (1200 + limit - 1) / limit; got != 3 {
t.Fatalf("totalPages for total=1200 at requested limit=1000 = %d, want 3", got)
}
}
func assertRecoveryMetaUsesNormalizedLimit(t *testing.T, body []byte, wantLimit int) {
t.Helper()
var resp struct {
Meta struct {
Page int `json:"page"`
Limit int `json:"limit"`
Total int `json:"total"`
TotalPages int `json:"totalPages"`
} `json:"meta"`
}
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if resp.Meta.Limit != wantLimit {
t.Fatalf("meta.limit = %d, want normalized %d", resp.Meta.Limit, wantLimit)
}
if resp.Meta.Page != 1 {
t.Fatalf("meta.page = %d, want 1", resp.Meta.Page)
}
wantPages := (resp.Meta.Total + wantLimit - 1) / wantLimit
if resp.Meta.TotalPages != wantPages {
t.Fatalf(
"meta.totalPages = %d, want %d (total %d at effective limit %d)",
resp.Meta.TotalPages, wantPages, resp.Meta.Total, wantLimit,
)
}
}
func TestHandleListRollupsMetaReportsNormalizedPagination(t *testing.T) {
setMockModeForTest(t, true)
tests := []struct {
name string
rawLimit string
wantLimit int
}{
{name: "limit above max reports the 500 cap", rawLimit: "1000", wantLimit: 500},
{name: "limit zero reports the default 100", rawLimit: "0", wantLimit: 100},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/recovery/rollups?limit="+tc.rawLimit, nil)
rec := httptest.NewRecorder()
NewRecoveryHandlers(nil).HandleListRollups(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("HandleListRollups() status = %d, want %d", rec.Code, http.StatusOK)
}
assertRecoveryMetaUsesNormalizedLimit(t, rec.Body.Bytes(), tc.wantLimit)
})
}
}
func TestHandleListPointsMetaReportsNormalizedPagination(t *testing.T) {
setMockModeForTest(t, true)
tests := []struct {
name string
rawLimit string
wantLimit int
}{
{name: "limit above max reports the 500 cap", rawLimit: "1000", wantLimit: 500},
{name: "limit zero reports the default 100", rawLimit: "0", wantLimit: 100},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/recovery/points?limit="+tc.rawLimit, nil)
rec := httptest.NewRecorder()
NewRecoveryHandlers(nil).HandleListPoints(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("HandleListPoints() status = %d, want %d", rec.Code, http.StatusOK)
}
assertRecoveryMetaUsesNormalizedLimit(t, rec.Body.Bytes(), tc.wantLimit)
})
}
}
func TestBuildRecoveryRollupPayloadExposesCanonicalItemResourceIDField(t *testing.T) {
verifiedAt := time.Date(2026, 7, 19, 6, 0, 0, 0, time.UTC)
payload := buildRecoveryRollupPayload(recovery.ProtectionRollup{