package server import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "scm.bstein.dev/bstein/soteria/internal/api" "scm.bstein.dev/bstein/soteria/internal/config" "scm.bstein.dev/bstein/soteria/internal/k8s" "scm.bstein.dev/bstein/soteria/internal/longhorn" ) func assertErrorResponseContains(t *testing.T, res *httptest.ResponseRecorder, wantStatus int, want string) { t.Helper() if res.Code != wantStatus { t.Fatalf("expected %d, got %d: %s", wantStatus, res.Code, res.Body.String()) } var payload map[string]string if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { t.Fatalf("decode error response: %v", err) } if got := payload["error"]; !strings.Contains(got, want) { t.Fatalf("expected error containing %q, got %q", want, got) } } type backupTestKubeClient struct { *restoreTestKubeClient createBackupErr error listBackupJobsPVCErr error } func (k *backupTestKubeClient) CreateBackupJob(ctx context.Context, cfg *config.Config, req api.BackupRequest) (string, string, error) { if k.createBackupErr != nil { return "", "", k.createBackupErr } return k.restoreTestKubeClient.fakeKubeClient.CreateBackupJob(ctx, cfg, req) } func (k *backupTestKubeClient) ListBackupJobsForPVC(ctx context.Context, namespace, pvc string) ([]k8s.BackupJobSummary, error) { if k.listBackupJobsPVCErr != nil { return nil, k.listBackupJobsPVCErr } return k.restoreTestKubeClient.fakeKubeClient.ListBackupJobsForPVC(ctx, namespace, pvc) } type backupTestLonghornClient struct { *restoreTestLonghornClient listBackups []longhorn.Backup listBackupsErr error createSnapErr error snapshotErr error } func (l *backupTestLonghornClient) ListBackups(ctx context.Context, volumeName string) ([]longhorn.Backup, error) { if l.listBackupsErr != nil { return nil, l.listBackupsErr } if l.listBackups != nil { return l.listBackups, nil } return l.restoreTestLonghornClient.fakeLonghornClient.ListBackups(ctx, volumeName) } func (l *backupTestLonghornClient) CreateSnapshot(ctx context.Context, volume, name string, labels map[string]string) error { if l.createSnapErr != nil { return l.createSnapErr } return l.restoreTestLonghornClient.fakeLonghornClient.CreateSnapshot(ctx, volume, name, labels) } func (l *backupTestLonghornClient) SnapshotBackup(ctx context.Context, volume, name string, labels map[string]string, backupMode string) (*longhorn.Volume, error) { if l.snapshotErr != nil { return nil, l.snapshotErr } return l.restoreTestLonghornClient.fakeLonghornClient.SnapshotBackup(ctx, volume, name, labels, backupMode) } func newBackupTestServer(cfg *config.Config, client kubeClient, longhornClient longhornClient) *Server { return newRestoreTestServer(cfg, client, longhornClient) } func TestHandleInventoryRejectsUnsupportedMethodAndBackendError(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{ fakeKubeClient: &fakeKubeClient{}, listPVCsErr: errors.New("inventory exploded"), }}, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) req := httptest.NewRequest(http.MethodPost, "/v1/inventory", nil) res := httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) if res.Code != http.StatusMethodNotAllowed { t.Fatalf("expected 405, got %d: %s", res.Code, res.Body.String()) } req = httptest.NewRequest(http.MethodGet, "/v1/inventory", nil) res = httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) assertErrorResponseContains(t, res, http.StatusBadGateway, "inventory exploded") } func TestHandleBackupsRejectsInvalidRequestsAndBackendErrors(t *testing.T) { t.Run("invalid requests", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{ fakeKubeClient: &fakeKubeClient{}, }}, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) req := httptest.NewRequest(http.MethodPost, "/v1/backups", nil) res := httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) if res.Code != http.StatusMethodNotAllowed { t.Fatalf("expected 405, got %d: %s", res.Code, res.Body.String()) } req = httptest.NewRequest(http.MethodGet, "/v1/backups", nil) res = httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) assertErrorResponseContains(t, res, http.StatusBadRequest, "namespace and pvc are required") }) t.Run("resolve pvc error", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{ fakeKubeClient: &fakeKubeClient{}, resolveErr: errors.New("resolve backup pvc exploded"), }}, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) req := httptest.NewRequest(http.MethodGet, "/v1/backups?namespace=apps&pvc=data", nil) res := httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) assertErrorResponseContains(t, res, http.StatusBadRequest, "resolve backup pvc exploded") }) t.Run("longhorn backend error", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, &backupTestLonghornClient{ restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}, listBackupsErr: errors.New("list backups exploded"), }, ) req := httptest.NewRequest(http.MethodGet, "/v1/backups?namespace=apps&pvc=data", nil) res := httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) assertErrorResponseContains(t, res, http.StatusBadGateway, "list backups exploded") }) t.Run("restic backend error", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "restic"}, &backupTestKubeClient{ restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}, listBackupJobsPVCErr: errors.New("list jobs exploded"), }, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) req := httptest.NewRequest(http.MethodGet, "/v1/backups?namespace=apps&pvc=data", nil) res := httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) assertErrorResponseContains(t, res, http.StatusBadGateway, "list jobs exploded") }) t.Run("unsupported driver", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "mystery"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) req := httptest.NewRequest(http.MethodGet, "/v1/backups?namespace=apps&pvc=data", nil) res := httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) assertErrorResponseContains(t, res, http.StatusBadRequest, "unsupported backup driver") }) } func TestBackupHandlersSuccessPaths(t *testing.T) { t.Run("inventory success", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "longhorn", BackupMaxAge: 24 * time.Hour}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{ fakeKubeClient: &fakeKubeClient{ pvcs: []k8s.PVCSummary{{Namespace: "apps", Name: "data", VolumeName: "pv-data", Phase: "Bound"}}, }, }}, &backupTestLonghornClient{ restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}, listBackups: []longhorn.Backup{{Name: "backup-a", Created: time.Now().UTC().Format(time.RFC3339), State: "Completed", Size: "1Gi"}}, }, ) req := httptest.NewRequest(http.MethodGet, "/v1/inventory", nil) res := httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"namespaces"`) { t.Fatalf("expected inventory success, got %d %s", res.Code, res.Body.String()) } }) t.Run("backups success longhorn", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, &backupTestLonghornClient{ restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}, listBackups: []longhorn.Backup{{Name: "backup-a", SnapshotName: "snap-a", Created: "2026-04-20T00:00:00Z", State: "Completed", URL: "s3://bucket/a", Size: "1Gi"}}, }, ) req := httptest.NewRequest(http.MethodGet, "/v1/backups?namespace=apps&pvc=data", nil) res := httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"backup-a"`) { t.Fatalf("expected longhorn backups success, got %d %s", res.Code, res.Body.String()) } }) t.Run("backups success restic", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "restic", ResticRepository: "s3:https://repo/root"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{ backupJobs: map[string][]k8s.BackupJobSummary{ "apps/data": {{Name: "restic-job", Namespace: "apps", PVC: "data", State: "Completed", Repository: "s3:https://repo/root", CreatedAt: time.Now().UTC()}}, }, }}}, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) req := httptest.NewRequest(http.MethodGet, "/v1/backups?namespace=apps&pvc=data", nil) res := httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"restic-job"`) { t.Fatalf("expected restic backups success, got %d %s", res.Code, res.Body.String()) } }) t.Run("handle backup success", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "restic"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) req := httptest.NewRequest(http.MethodPost, "/v1/backup", strings.NewReader(`{"namespace":"apps","pvc":"data","dry_run":true}`)) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"dry_run":true`) { t.Fatalf("expected backup success, got %d %s", res.Code, res.Body.String()) } }) t.Run("namespace backup success", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "restic"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{ pvcs: []k8s.PVCSummary{ {Namespace: "apps", Name: "alpha", VolumeName: "pv-alpha", Phase: "Bound"}, {Namespace: "apps", Name: "beta", VolumeName: "pv-beta", Phase: "Bound"}, }, }}}, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) req := httptest.NewRequest(http.MethodPost, "/v1/backup/namespace", strings.NewReader(`{"namespace":"apps","dry_run":true}`)) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"total":2`) || !strings.Contains(res.Body.String(), `"dry_run":true`) { t.Fatalf("expected namespace backup success, got %d %s", res.Code, res.Body.String()) } }) } func TestHandleBackupAndNamespaceBackupValidationPaths(t *testing.T) { t.Run("backup request validation", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "restic"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) testCases := []struct { name string body string code int want string }{ {name: "method", body: "", code: http.StatusMethodNotAllowed, want: "method not allowed"}, {name: "invalid json", body: `{"namespace":`, code: http.StatusBadRequest, want: "invalid JSON"}, {name: "missing namespace", body: `{"pvc":"data"}`, code: http.StatusBadRequest, want: "namespace and pvc are required"}, {name: "invalid keep last", body: `{"namespace":"apps","pvc":"data","keep_last":-1}`, code: http.StatusBadRequest, want: "keep_last must be >="}, } for _, tc := range testCases { method := http.MethodPost if tc.name == "method" { method = http.MethodGet } req := httptest.NewRequest(method, "/v1/backup", strings.NewReader(tc.body)) if method == http.MethodPost { req.Header.Set("Content-Type", "application/json") } res := httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) assertErrorResponseContains(t, res, tc.code, tc.want) } }) t.Run("namespace backup validation and backend", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "restic"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{ fakeKubeClient: &fakeKubeClient{}, listPVCsErr: errors.New("list namespace pvc exploded"), }}, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) testCases := []struct { name string body string code int want string }{ {name: "invalid json", body: `{"namespace":`, code: http.StatusBadRequest, want: "invalid JSON"}, {name: "missing namespace", body: `{}`, code: http.StatusBadRequest, want: "namespace is required"}, {name: "invalid keep last", body: `{"namespace":"apps","keep_last":-1}`, code: http.StatusBadRequest, want: "keep_last must be >="}, {name: "invalid namespace", body: `{"namespace":"Bad_Ns"}`, code: http.StatusBadRequest, want: "namespace must be a valid Kubernetes DNS-1123 label"}, {name: "backend error", body: `{"namespace":"apps"}`, code: http.StatusBadGateway, want: "list namespace pvc exploded"}, } for _, tc := range testCases { req := httptest.NewRequest(http.MethodPost, "/v1/backup/namespace", strings.NewReader(tc.body)) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() srv.Handler().ServeHTTP(res, req) assertErrorResponseContains(t, res, tc.code, tc.want) } }) } func TestExecuteBackupAndStatusHelpers(t *testing.T) { t.Run("validation error", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) _, result, err := srv.executeBackup(context.Background(), api.BackupRequest{}, "brad") if result != "validation_error" || err == nil { t.Fatalf("expected validation error, got result=%q err=%v", result, err) } }) t.Run("longhorn resolve error", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{ fakeKubeClient: &fakeKubeClient{}, resolveErr: errors.New("resolve backup pvc exploded"), }}, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) _, result, err := srv.executeBackup(context.Background(), api.BackupRequest{Namespace: "apps", PVC: "data"}, "brad") if result != "validation_error" || err == nil || !strings.Contains(err.Error(), "resolve backup pvc exploded") { t.Fatalf("expected resolve error, got result=%q err=%v", result, err) } }) t.Run("longhorn snapshot error", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, &backupTestLonghornClient{ restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}, createSnapErr: errors.New("create snapshot exploded"), }, ) _, result, err := srv.executeBackup(context.Background(), api.BackupRequest{Namespace: "apps", PVC: "data"}, "brad") if result != "backend_error" || err == nil || !strings.Contains(err.Error(), "create snapshot exploded") { t.Fatalf("expected snapshot backend error, got result=%q err=%v", result, err) } }) t.Run("longhorn backup error", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, &backupTestLonghornClient{ restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}, snapshotErr: errors.New("snapshot backup exploded"), }, ) _, result, err := srv.executeBackup(context.Background(), api.BackupRequest{Namespace: "apps", PVC: "data"}, "brad") if result != "backend_error" || err == nil || !strings.Contains(err.Error(), "snapshot backup exploded") { t.Fatalf("expected backup backend error, got result=%q err=%v", result, err) } }) t.Run("restic backend error", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "restic"}, &backupTestKubeClient{ restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}, createBackupErr: errors.New("create backup job exploded"), }, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) _, result, err := srv.executeBackup(context.Background(), api.BackupRequest{Namespace: "apps", PVC: "data"}, "brad") if result != "backend_error" || err == nil || !strings.Contains(err.Error(), "create backup job exploded") { t.Fatalf("expected restic backend error, got result=%q err=%v", result, err) } }) t.Run("unsupported driver", func(t *testing.T) { srv := newBackupTestServer( &config.Config{AuthRequired: false, BackupDriver: "mystery"}, &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, ) _, result, err := srv.executeBackup(context.Background(), api.BackupRequest{Namespace: "apps", PVC: "data"}, "brad") if result != "unsupported_driver" || err == nil || !strings.Contains(err.Error(), "unsupported backup driver") { t.Fatalf("expected unsupported driver error, got result=%q err=%v", result, err) } }) t.Run("status helpers", func(t *testing.T) { testCases := map[string]int{ "validation_error": http.StatusBadRequest, "unsupported_driver": http.StatusBadRequest, "backend_error": http.StatusBadGateway, "other": http.StatusInternalServerError, } for result, want := range testCases { if got := backupStatusCode(result); got != want { t.Fatalf("backupStatusCode(%q): expected %d, got %d", result, want, got) } } if got := namespaceResultStatus(true, 2, 2, 0); got != "dry_run" { t.Fatalf("expected dry_run namespace result, got %q", got) } if got := namespaceResultStatus(false, 0, 0, 0); got != "empty" { t.Fatalf("expected empty namespace result, got %q", got) } if got := namespaceResultStatus(false, 2, 2, 0); got != "success" { t.Fatalf("expected success namespace result, got %q", got) } if got := namespaceResultStatus(false, 2, 0, 2); got != "failed" { t.Fatalf("expected failed namespace result, got %q", got) } if got := namespaceResultStatus(false, 2, 1, 1); got != "partial" { t.Fatalf("expected partial namespace result, got %q", got) } }) }