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)
+ }
}
}