test(recovery): prove malformed metadata tolerance

This commit is contained in:
rcourtman
2026-03-26 22:43:26 +00:00
parent 71a1737c4f
commit dc987d2d41
+207
View File
@@ -1,14 +1,21 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager"
_ "modernc.org/sqlite"
)
func TestParseRecoveryPlatformQuery(t *testing.T) {
@@ -240,3 +247,203 @@ func TestBuildRecoveryRollupPayloadExposesCanonicalItemRefField(t *testing.T) {
t.Fatalf("payload.SubjectRef = %#v, want compatibility subject ref", payload.SubjectRef)
}
}
func TestHandleListPointsToleratesMalformedPersistedMetadata(t *testing.T) {
t.Parallel()
handler, dbPath := newRecoveryHandlerWithPersistedPoint(t, recovery.RecoveryPoint{
ID: "point-bad-json",
Provider: recovery.ProviderKubernetes,
Kind: recovery.KindSnapshot,
Mode: recovery.ModeSnapshot,
Outcome: recovery.OutcomeSuccess,
StartedAt: timePtr(time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC)),
CompletedAt: timePtr(time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC)),
SubjectRef: &recovery.ExternalRef{
Type: "k8s-pvc",
Namespace: "default",
Name: "data",
},
RepositoryRef: &recovery.ExternalRef{
Type: "velero-backup-storage-location",
Name: "repo-a",
},
Details: map[string]any{"foo": "bar"},
})
corruptRecoveryRowJSON(t, dbPath, "point-bad-json", true, true, true)
req := httptest.NewRequest(http.MethodGet, "/api/recovery/points?limit=500", nil)
rec := httptest.NewRecorder()
handler.HandleListPoints(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("HandleListPoints() status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Data []struct {
ID string `json:"id"`
ItemRef map[string]any `json:"itemRef"`
SubjectRef map[string]any `json:"subjectRef"`
RepositoryRef map[string]any `json:"repositoryRef"`
Details map[string]any `json:"details"`
Display struct {
SubjectLabel string `json:"subjectLabel"`
ItemType string `json:"itemType"`
} `json:"display"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected exactly 1 point, got %d", len(resp.Data))
}
point := resp.Data[0]
if point.ID != "point-bad-json" {
t.Fatalf("point.ID = %q, want point-bad-json", point.ID)
}
if point.ItemRef != nil || point.SubjectRef != nil || point.RepositoryRef != nil || point.Details != nil {
t.Fatalf("expected malformed metadata fields to be omitted, got itemRef=%#v subjectRef=%#v repositoryRef=%#v details=%#v", point.ItemRef, point.SubjectRef, point.RepositoryRef, point.Details)
}
if point.Display.SubjectLabel != "default/data" {
t.Fatalf("display.subjectLabel = %q, want %q", point.Display.SubjectLabel, "default/data")
}
if point.Display.ItemType != "pvc" {
t.Fatalf("display.itemType = %q, want %q", point.Display.ItemType, "pvc")
}
}
func TestHandleListRollupsToleratesMalformedPersistedMetadata(t *testing.T) {
t.Parallel()
handler, dbPath := newRecoveryHandlerWithPersistedPoint(t, recovery.RecoveryPoint{
ID: "rollup-bad-json",
Provider: recovery.ProviderTrueNAS,
Kind: recovery.KindSnapshot,
Mode: recovery.ModeSnapshot,
Outcome: recovery.OutcomeSuccess,
StartedAt: timePtr(time.Date(2026, 2, 21, 12, 0, 0, 0, time.UTC)),
CompletedAt: timePtr(time.Date(2026, 2, 21, 12, 0, 0, 0, time.UTC)),
SubjectRef: &recovery.ExternalRef{
Type: "truenas-dataset",
Name: "tank/apps",
ID: "tank/apps",
},
})
corruptRecoveryRowJSON(t, dbPath, "rollup-bad-json", true, false, false)
req := httptest.NewRequest(http.MethodGet, "/api/recovery/rollups?limit=500", nil)
rec := httptest.NewRecorder()
handler.HandleListRollups(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("HandleListRollups() status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Data []struct {
RollupID string `json:"rollupId"`
ItemRef map[string]any `json:"itemRef"`
SubjectRef map[string]any `json:"subjectRef"`
LastOutcome string `json:"lastOutcome"`
Display struct {
SubjectLabel string `json:"subjectLabel"`
ItemType string `json:"itemType"`
} `json:"display"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected exactly 1 rollup, got %d", len(resp.Data))
}
rollup := resp.Data[0]
if rollup.ItemRef != nil || rollup.SubjectRef != nil {
t.Fatalf("expected malformed item refs to be omitted, got itemRef=%#v subjectRef=%#v", rollup.ItemRef, rollup.SubjectRef)
}
if rollup.Display.SubjectLabel != "tank/apps" {
t.Fatalf("display.subjectLabel = %q, want %q", rollup.Display.SubjectLabel, "tank/apps")
}
if rollup.Display.ItemType != "dataset" {
t.Fatalf("display.itemType = %q, want %q", rollup.Display.ItemType, "dataset")
}
if rollup.LastOutcome != "success" {
t.Fatalf("lastOutcome = %q, want %q", rollup.LastOutcome, "success")
}
}
func newRecoveryHandlerWithPersistedPoint(t *testing.T, point recovery.RecoveryPoint) (*RecoveryHandlers, string) {
t.Helper()
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
manager := recoverymanager.New(mtp)
store, err := manager.StoreForOrg("default")
if err != nil {
t.Fatalf("StoreForOrg(default): %v", err)
}
if err := store.UpsertPoints(context.Background(), []recovery.RecoveryPoint{point}); err != nil {
t.Fatalf("UpsertPoints(): %v", err)
}
persistence, err := mtp.GetPersistence("default")
if err != nil {
t.Fatalf("GetPersistence(default): %v", err)
}
return NewRecoveryHandlers(manager), filepath.Join(persistence.DataDir(), "recovery", "recovery.db")
}
func corruptRecoveryRowJSON(t *testing.T, dbPath string, rowID string, corruptSubject bool, corruptRepository bool, corruptDetails bool) {
t.Helper()
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("sql.Open(%q): %v", dbPath, err)
}
t.Cleanup(func() { _ = db.Close() })
assignments := make([]string, 0, 3)
if corruptSubject {
assignments = append(assignments, "subject_ref_json = '{'")
}
if corruptRepository {
assignments = append(assignments, "repository_ref_json = '{'")
}
if corruptDetails {
assignments = append(assignments, "details_json = '{'")
}
if len(assignments) == 0 {
t.Fatal("corruptRecoveryRowJSON called without any fields to corrupt")
}
query := "UPDATE recovery_points SET " + joinCSV(assignments) + " WHERE id = ?"
if _, err := db.ExecContext(context.Background(), query, rowID); err != nil {
t.Fatalf("corrupt recovery row json: %v", err)
}
}
func joinCSV(parts []string) string {
switch len(parts) {
case 0:
return ""
case 1:
return parts[0]
default:
out := parts[0]
for _, part := range parts[1:] {
out += ", " + part
}
return out
}
}
func timePtr(t time.Time) *time.Time {
return &t
}