diff --git a/internal/k8s/jobs_test.go b/internal/k8s/jobs_test.go index 95755ee..73e3d39 100644 --- a/internal/k8s/jobs_test.go +++ b/internal/k8s/jobs_test.go @@ -2,6 +2,7 @@ package k8s import ( "context" + "errors" "strings" "testing" "time" @@ -12,7 +13,9 @@ import ( batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" k8sfake "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" ) func TestListBackupJobsAndListBackupJobsForPVCCoverFilteringAndSorting(t *testing.T) { @@ -166,6 +169,48 @@ func TestResolvePVCMountedNodeIgnoresDeadPodsAndFindsMountedClaim(t *testing.T) } } +func TestReadBackupJobLogCoversSuccessAndListFailures(t *testing.T) { + client := &Client{Clientset: k8sfake.NewSimpleClientset( + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job-pod-old", + Namespace: "apps", + CreationTimestamp: metav1.NewTime(time.Now().UTC().Add(-2 * time.Hour)), + Labels: map[string]string{"job-name": "backup-job"}, + }, + Status: corev1.PodStatus{StartTime: ptrTime(metav1.NewTime(time.Now().UTC().Add(-90 * time.Minute)))}, + }, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job-pod-new", + Namespace: "apps", + CreationTimestamp: metav1.NewTime(time.Now().UTC().Add(-1 * time.Hour)), + Labels: map[string]string{"job-name": "backup-job"}, + }, + Status: corev1.PodStatus{StartTime: ptrTime(metav1.NewTime(time.Now().UTC().Add(-30 * time.Minute)))}, + }, + )} + + logs, err := client.ReadBackupJobLog(context.Background(), "apps", "backup-job") + if err != nil || logs != "fake logs" { + t.Fatalf("expected fake pod logs response, got %q %v", logs, err) + } + + emptyClient := &Client{Clientset: k8sfake.NewSimpleClientset()} + if _, err := emptyClient.ReadBackupJobLog(context.Background(), "apps", "backup-job"); err == nil || !strings.Contains(err.Error(), "no pod found") { + t.Fatalf("expected missing pod error, got %v", err) + } + + clientset := k8sfake.NewSimpleClientset() + clientset.PrependReactor("list", "pods", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("list pods exploded") + }) + listFailClient := &Client{Clientset: clientset} + if _, err := listFailClient.ReadBackupJobLog(context.Background(), "apps", "backup-job"); err == nil || !strings.Contains(err.Error(), "list pods exploded") { + t.Fatalf("expected wrapped pod list error, got %v", err) + } +} + func TestCreateBackupJobCoversValidationDryRunAndLiveCreation(t *testing.T) { clientset := k8sfake.NewSimpleClientset( &corev1.Secret{ @@ -252,6 +297,73 @@ func TestCreateBackupJobCoversValidationDryRunAndLiveCreation(t *testing.T) { } } +func TestCreateBackupJobCleansUpSecretOnJobCreateFailureAndSurfacesBindFailure(t *testing.T) { + clientset := k8sfake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "restic-src", Namespace: "shared"}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "AWS_ACCESS_KEY_ID": []byte("abc"), + "AWS_SECRET_ACCESS_KEY": []byte("def"), + "RESTIC_PASSWORD": []byte("ghi"), + }, + }, + ) + cfg := &config.Config{ + SecretNamespace: "shared", + ResticSecretName: "restic-src", + ResticRepository: "s3:https://repo/root", + ResticImage: "restic/restic:latest", + JobTTLSeconds: 3600, + } + + clientset.PrependReactor("create", "jobs", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("create job exploded") + }) + client := &Client{Clientset: clientset} + if _, secretName, err := client.CreateBackupJob(context.Background(), cfg, api.BackupRequest{ + Namespace: "apps", + PVC: "data", + }); err == nil || !strings.Contains(err.Error(), "create job exploded") { + t.Fatalf("expected job create error, got secret=%q err=%v", secretName, err) + } + secrets, err := client.Clientset.CoreV1().Secrets("apps").List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("list secrets after failed backup create: %v", err) + } + if len(secrets.Items) != 0 { + t.Fatalf("expected copied secret cleanup on create failure, got %#v", secrets.Items) + } + + bindFailClientset := k8sfake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "restic-src", Namespace: "shared"}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "AWS_ACCESS_KEY_ID": []byte("abc"), + "AWS_SECRET_ACCESS_KEY": []byte("def"), + "RESTIC_PASSWORD": []byte("ghi"), + }, + }, + ) + bindFailClientset.PrependReactor("update", "secrets", func(action k8stesting.Action) (bool, runtime.Object, error) { + update := action.(k8stesting.UpdateAction) + secret := update.GetObject().(*corev1.Secret) + if secret.Namespace == "apps" { + return true, nil, errors.New("bind secret exploded") + } + return false, nil, nil + }) + bindFailClient := &Client{Clientset: bindFailClientset} + jobName, secretName, err := bindFailClient.CreateBackupJob(context.Background(), cfg, api.BackupRequest{ + Namespace: "apps", + PVC: "data", + }) + if err == nil || !strings.Contains(err.Error(), "bind secret exploded") || jobName == "" || secretName == "" { + t.Fatalf("expected bind failure after backup job create, got job=%q secret=%q err=%v", jobName, secretName, err) + } +} + func TestCreateRestoreJobCoversValidationDryRunAndLiveCreation(t *testing.T) { clientset := k8sfake.NewSimpleClientset( &corev1.Secret{ @@ -317,3 +429,67 @@ func TestCreateRestoreJobCoversValidationDryRunAndLiveCreation(t *testing.T) { t.Fatalf("expected restore job owner reference on copied secret, got %#v", secret.OwnerReferences) } } + +func TestCreateRestoreJobCleansUpSecretOnJobCreateFailureAndSurfacesBindFailure(t *testing.T) { + cfg := &config.Config{ + SecretNamespace: "shared", + ResticSecretName: "restic-src", + ResticRepository: "s3:https://repo/root", + ResticImage: "restic/restic:latest", + JobTTLSeconds: 3600, + } + clientset := k8sfake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "restic-src", Namespace: "shared"}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "AWS_ACCESS_KEY_ID": []byte("abc"), + "AWS_SECRET_ACCESS_KEY": []byte("def"), + "RESTIC_PASSWORD": []byte("ghi"), + }, + }, + ) + clientset.PrependReactor("create", "jobs", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("create restore job exploded") + }) + client := &Client{Clientset: clientset} + if _, _, err := client.CreateRestoreJob(context.Background(), cfg, api.RestoreTestRequest{Namespace: "apps"}); err == nil || !strings.Contains(err.Error(), "create restore job exploded") { + t.Fatalf("expected restore job create error, got %v", err) + } + secrets, err := client.Clientset.CoreV1().Secrets("apps").List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("list secrets after failed restore create: %v", err) + } + if len(secrets.Items) != 0 { + t.Fatalf("expected copied secret cleanup on restore create failure, got %#v", secrets.Items) + } + + bindFailClientset := k8sfake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "restic-src", Namespace: "shared"}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "AWS_ACCESS_KEY_ID": []byte("abc"), + "AWS_SECRET_ACCESS_KEY": []byte("def"), + "RESTIC_PASSWORD": []byte("ghi"), + }, + }, + ) + bindFailClientset.PrependReactor("update", "secrets", func(action k8stesting.Action) (bool, runtime.Object, error) { + update := action.(k8stesting.UpdateAction) + secret := update.GetObject().(*corev1.Secret) + if secret.Namespace == "apps" { + return true, nil, errors.New("bind restore secret exploded") + } + return false, nil, nil + }) + bindFailClient := &Client{Clientset: bindFailClientset} + jobName, secretName, err := bindFailClient.CreateRestoreJob(context.Background(), cfg, api.RestoreTestRequest{Namespace: "apps"}) + if err == nil || !strings.Contains(err.Error(), "bind restore secret exploded") || jobName == "" || secretName == "" { + t.Fatalf("expected restore bind failure after job create, got job=%q secret=%q err=%v", jobName, secretName, err) + } +} + +func ptrTime(value metav1.Time) *metav1.Time { + return &value +} diff --git a/internal/server/b2_scan_test.go b/internal/server/b2_scan_test.go new file mode 100644 index 0000000..3e361e4 --- /dev/null +++ b/internal/server/b2_scan_test.go @@ -0,0 +1,144 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" + "time" +) + +type fakeS3Object struct { + key string + size int64 + lastModified time.Time +} + +func newFakeS3Server(t *testing.T, buckets []string, objects map[string][]fakeS3Object, missing map[string]bool) *httptest.Server { + t.Helper() + + sort.Strings(buckets) + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + switch { + case r.URL.Path == "/" && r.URL.RawQuery == "": + fmt.Fprint(w, ``) + for _, bucket := range buckets { + fmt.Fprintf(w, `%s2026-04-20T00:00:00Z`, bucket) + } + fmt.Fprint(w, ``) + case strings.HasSuffix(r.URL.Path, "/") && r.URL.Query().Has("location"): + fmt.Fprint(w, `us-west-001`) + case strings.HasSuffix(r.URL.Path, "/") && r.URL.Query().Get("list-type") == "2": + bucket := strings.Trim(strings.TrimSuffix(r.URL.Path, "/"), "/") + if missing[bucket] { + http.Error(w, "no such bucket", http.StatusNotFound) + return + } + fmt.Fprintf(w, `%sfalse`, bucket) + for _, object := range objects[bucket] { + fmt.Fprintf(w, `%s%s%d`, + object.key, + object.lastModified.UTC().Format(time.RFC3339), + object.size, + ) + } + fmt.Fprint(w, ``) + default: + http.Error(w, "unexpected request", http.StatusNotFound) + } + })) +} + +func TestScanB2UsageAutoDiscoversBucketsAndAggregatesObjects(t *testing.T) { + now := time.Now().UTC() + server := newFakeS3Server(t, + []string{"zeta", "alpha"}, + map[string][]fakeS3Object{ + "alpha": { + {key: "alpha-new", size: 12, lastModified: now.Add(-2 * time.Hour)}, + {key: "alpha-old", size: 30, lastModified: now.Add(-48 * time.Hour)}, + }, + "zeta": { + {key: "zeta-new", size: 8, lastModified: now.Add(-1 * time.Hour)}, + }, + }, + nil, + ) + defer server.Close() + + result, err := scanB2Usage(context.Background(), b2Credentials{ + Endpoint: server.URL, + Region: "us-west-001", + AccessKeyID: "atlas-key", + SecretAccessKey: "atlas-secret", + }, nil) + if err != nil { + t.Fatalf("scan b2 usage autodiscovery: %v", err) + } + + if !result.Enabled || !result.Available { + t.Fatalf("expected available B2 usage result, got %#v", result) + } + if len(result.Buckets) != 2 || result.Buckets[0].Name != "alpha" || result.Buckets[1].Name != "zeta" { + t.Fatalf("expected sorted bucket results, got %#v", result.Buckets) + } + if result.TotalObjects != 3 || result.TotalBytes != 50 { + t.Fatalf("expected aggregated object totals, got %#v", result) + } + if result.RecentObjects24h != 2 || result.RecentBytes24h != 20 { + t.Fatalf("expected recent object totals, got %#v", result) + } + if result.Buckets[0].RecentObjects24h != 1 || result.Buckets[0].RecentBytes24h != 12 || result.Buckets[0].LastModifiedAt == "" { + t.Fatalf("expected alpha bucket recent stats, got %#v", result.Buckets[0]) + } +} + +func TestScanB2UsageConfiguredBucketsAndErrorBranches(t *testing.T) { + now := time.Now().UTC() + server := newFakeS3Server(t, + []string{}, + map[string][]fakeS3Object{ + "alpha": {{key: "alpha", size: 1, lastModified: now.Add(-1 * time.Hour)}}, + "beta": {{key: "beta", size: 2, lastModified: now.Add(-2 * time.Hour)}}, + }, + map[string]bool{"missing": true}, + ) + defer server.Close() + + result, err := scanB2Usage(context.Background(), b2Credentials{ + Endpoint: server.URL, + Region: "us-west-001", + AccessKeyID: "atlas-key", + SecretAccessKey: "atlas-secret", + }, []string{" beta ", "", "alpha"}) + if err != nil { + t.Fatalf("scan b2 usage configured buckets: %v", err) + } + if len(result.Buckets) != 2 || result.Buckets[0].Name != "alpha" || result.Buckets[1].Name != "beta" { + t.Fatalf("expected configured buckets to be trimmed and sorted, got %#v", result.Buckets) + } + + emptyServer := newFakeS3Server(t, nil, nil, nil) + defer emptyServer.Close() + if _, err := scanB2Usage(context.Background(), b2Credentials{ + Endpoint: emptyServer.URL, + Region: "us-west-001", + AccessKeyID: "atlas-key", + SecretAccessKey: "atlas-secret", + }, nil); err == nil || !strings.Contains(err.Error(), "no B2 buckets available for scan") { + t.Fatalf("expected no-buckets error, got %v", err) + } + + if _, err := scanB2Usage(context.Background(), b2Credentials{ + Endpoint: server.URL, + Region: "us-west-001", + AccessKeyID: "atlas-key", + SecretAccessKey: "atlas-secret", + }, []string{"missing"}); err == nil || !strings.Contains(err.Error(), "scan B2 bucket missing") { + t.Fatalf("expected bucket scan error, got %v", err) + } +} diff --git a/internal/server/policy_runtime_test.go b/internal/server/policy_runtime_test.go index 1c8acb3..56b3168 100644 --- a/internal/server/policy_runtime_test.go +++ b/internal/server/policy_runtime_test.go @@ -2,6 +2,7 @@ package server import ( "context" + "math" "strings" "testing" "time" @@ -183,3 +184,70 @@ func TestLoadPoliciesRejectsInvalidDocuments(t *testing.T) { t.Fatalf("expected invalid document error, got %v", err) } } + +func TestLoadPoliciesAppliesDefaultsAndSkipsInvalidEntries(t *testing.T) { + srv := &Server{ + cfg: &config.Config{ + Namespace: "maintenance", + PolicySecretName: "soteria-policies", + }, + client: &fakeKubeClient{ + secretData: map[string][]byte{ + policySecretKey: []byte(`{ + "policies": [ + {"namespace":"apps","pvc":"data","interval_hours":0,"enabled":true}, + {"namespace":" ","pvc":"skip","interval_hours":4,"enabled":true}, + {"namespace":"ops","interval_hours":12,"enabled":false,"dedupe":false,"keep_last":3,"created_at":"2026-04-20T00:00:00Z","updated_at":"2026-04-20T01:00:00Z"} + ] + }`), + }, + }, + policies: map[string]api.BackupPolicy{}, + } + + if err := srv.loadPolicies(context.Background()); err != nil { + t.Fatalf("load valid policies: %v", err) + } + + if len(srv.policies) != 2 { + t.Fatalf("expected two valid policies after filtering, got %#v", srv.policies) + } + + apps := srv.policies["apps__data"] + if apps.IntervalHours != defaultPolicyHours || !apps.Dedupe || apps.KeepLast != 0 || !apps.Enabled { + t.Fatalf("expected defaults for apps policy, got %#v", apps) + } + if apps.CreatedAt == "" || apps.UpdatedAt == "" { + t.Fatalf("expected timestamps to default, got %#v", apps) + } + + ops := srv.policies["ops___all"] + if ops.IntervalHours != 12 || ops.Dedupe || ops.KeepLast != 3 || ops.Enabled { + t.Fatalf("expected explicit ops policy values to persist, got %#v", ops) + } + if ops.CreatedAt != "2026-04-20T00:00:00Z" || ops.UpdatedAt != "2026-04-20T01:00:00Z" { + t.Fatalf("expected explicit timestamps to persist, got %#v", ops) + } +} + +func TestPersistPoliciesRejectsUnsupportedValues(t *testing.T) { + srv := &Server{ + cfg: &config.Config{ + Namespace: "maintenance", + PolicySecretName: "soteria-policies", + }, + client: &policyTestKubeClient{fakeKubeClient: &fakeKubeClient{}}, + } + + err := srv.persistPolicies(context.Background(), []api.BackupPolicy{ + { + ID: "apps__data", + Namespace: "apps", + PVC: "data", + IntervalHours: math.NaN(), + }, + }) + if err == nil || !strings.Contains(err.Error(), "encode policy document") { + t.Fatalf("expected persist encode error, got %v", err) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 059364d..ecac3eb 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -291,15 +291,15 @@ func TestRootFailsWhenUIRendererUnavailable(t *testing.T) { func TestStartSeedsInitialBackgroundState(t *testing.T) { srv := &Server{ cfg: &config.Config{ - AuthRequired: false, - BackupDriver: "longhorn", - BackupMaxAge: 24 * time.Hour, - MetricsRefreshInterval: time.Hour, - PolicyEvalInterval: time.Hour, - B2Enabled: false, - Namespace: "maintenance", - PolicySecretName: "soteria-policies", - UsageSecretName: "", + AuthRequired: false, + BackupDriver: "longhorn", + BackupMaxAge: 24 * time.Hour, + MetricsRefreshInterval: time.Hour, + PolicyEvalInterval: time.Hour, + B2Enabled: false, + Namespace: "maintenance", + PolicySecretName: "soteria-policies", + UsageSecretName: "", }, client: &fakeKubeClient{}, longhorn: &fakeLonghornClient{}, @@ -437,13 +437,42 @@ func TestResolveB2CredentialsLoadsSecretValues(t *testing.T) { } func TestResolveB2CredentialsRejectsMissingValues(t *testing.T) { - srv := &Server{ - cfg: &config.Config{}, - client: &fakeKubeClient{}, + testCases := []struct { + name string + cfg config.Config + want string + }{ + { + name: "missing endpoint", + cfg: config.Config{}, + want: "B2 endpoint is not configured", + }, + { + name: "missing access key", + cfg: config.Config{ + B2Endpoint: "https://s3.us-west-000.backblazeb2.com", + B2SecretAccessKey: "def", + }, + want: "B2 access key ID is not configured", + }, + { + name: "missing secret key", + cfg: config.Config{ + B2Endpoint: "https://s3.us-west-000.backblazeb2.com", + B2AccessKeyID: "abc", + }, + want: "B2 secret access key is not configured", + }, } - if _, err := srv.resolveB2Credentials(context.Background()); err == nil || !strings.Contains(err.Error(), "B2 endpoint is not configured") { - t.Fatalf("expected missing endpoint error, got %v", err) + for _, tc := range testCases { + srv := &Server{ + cfg: &tc.cfg, + client: &fakeKubeClient{}, + } + if _, err := srv.resolveB2Credentials(context.Background()); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("%s: expected error containing %q, got %v", tc.name, tc.want, err) + } } }