mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
Reference in New Issue
Block a user