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 `unifiedresources.IsPulseAgentPlatformResource`; new agent surfaces MUST use it
rather than testing `type == "agent"` or counting the `agent` source. 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 ## Forbidden Paths
1. New install or update continuity behavior hidden only inside broad monitoring ownership. 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 after a successful import-triggered reload request, returning a controlled
API outcome instead of panicking or leaving browser-visible state half API outcome instead of panicking or leaving browser-visible state half
rewired. 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 ### 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 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. 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 ## Forbidden Paths
1. Reintroducing storage or recovery product logic as ad hoc dashboard-only summaries without a canonical page-surface owner 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() qs := r.URL.Query()
page := parseIntQuery(qs, "page", 1) page := normalizeRecoveryPage(parseIntQuery(qs, "page", 1))
limit := parseIntQuery(qs, "limit", 100) limit := normalizeRecoveryLimit(parseIntQuery(qs, "limit", recoveryDefaultPageLimit))
var from, to *time.Time var from, to *time.Time
if t, err := parseRFC3339QueryTime(qs.Get("from")); err != nil { 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.Page = page
resp.Meta.Limit = limit resp.Meta.Limit = limit
resp.Meta.Total = total resp.Meta.Total = total
if limit <= 0 { resp.Meta.TotalPages = (total + limit - 1) / limit
resp.Meta.TotalPages = 1
} else {
resp.Meta.TotalPages = (total + limit - 1) / limit
}
if err := utils.WriteJSONResponse(w, resp); err != nil { if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to serialize recovery points response") 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) 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 { func paginateRecoveryPoints(filtered []recovery.RecoveryPoint, page int, limit int) []recovery.RecoveryPoint {
if len(filtered) == 0 { if len(filtered) == 0 {
return []recovery.RecoveryPoint{} return []recovery.RecoveryPoint{}
} }
normalizedLimit := limit normalizedLimit := normalizeRecoveryLimit(limit)
if normalizedLimit <= 0 { normalizedPage := normalizeRecoveryPage(page)
normalizedLimit = 100
}
if normalizedLimit > 500 {
normalizedLimit = 500
}
normalizedPage := page
if normalizedPage <= 0 {
normalizedPage = 1
}
offset := (normalizedPage - 1) * normalizedLimit offset := (normalizedPage - 1) * normalizedLimit
if offset >= len(filtered) { if offset >= len(filtered) {
@@ -618,8 +631,8 @@ func (h *RecoveryHandlers) HandleListRollups(w http.ResponseWriter, r *http.Requ
} }
qs := r.URL.Query() qs := r.URL.Query()
page := parseIntQuery(qs, "page", 1) page := normalizeRecoveryPage(parseIntQuery(qs, "page", 1))
limit := parseIntQuery(qs, "limit", 100) limit := normalizeRecoveryLimit(parseIntQuery(qs, "limit", recoveryDefaultPageLimit))
var from, to *time.Time var from, to *time.Time
if t, err := parseRFC3339QueryTime(qs.Get("from")); err != nil { 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, "page": page,
"limit": limit, "limit": limit,
"total": total, "total": total,
"totalPages": 0, "totalPages": (total + limit - 1) / limit,
}
if limit <= 0 {
meta["totalPages"] = 1
} else {
meta["totalPages"] = (total + limit - 1) / limit
} }
payloadRollups := make([]recoveryRollupPayload, 0, len(rollups)) payloadRollups := make([]recoveryRollupPayload, 0, len(rollups))
@@ -1094,17 +1102,8 @@ func paginateRecoveryRollups(filtered []recovery.ProtectionRollup, page int, lim
return []recovery.ProtectionRollup{} return []recovery.ProtectionRollup{}
} }
normalizedLimit := limit normalizedLimit := normalizeRecoveryLimit(limit)
if normalizedLimit <= 0 { normalizedPage := normalizeRecoveryPage(page)
normalizedLimit = 100
}
if normalizedLimit > 500 {
normalizedLimit = 500
}
normalizedPage := page
if normalizedPage <= 0 {
normalizedPage = 1
}
offset := (normalizedPage - 1) * normalizedLimit offset := (normalizedPage - 1) * normalizedLimit
if offset >= len(filtered) { 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) { func TestBuildRecoveryRollupPayloadExposesCanonicalItemResourceIDField(t *testing.T) {
verifiedAt := time.Date(2026, 7, 19, 6, 0, 0, 0, time.UTC) verifiedAt := time.Date(2026, 7, 19, 6, 0, 0, 0, time.UTC)
payload := buildRecoveryRollupPayload(recovery.ProtectionRollup{ payload := buildRecoveryRollupPayload(recovery.ProtectionRollup{