test(soteria): cover B2 scan and remaining policy/k8s edges

This commit is contained in:
codex 2026-04-20 19:36:23 -03:00
parent 5c9cca4420
commit 4a2febe148
4 changed files with 431 additions and 14 deletions

View File

@ -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
}

View File

@ -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, `<?xml version="1.0" encoding="UTF-8"?><ListAllMyBucketsResult><Buckets>`)
for _, bucket := range buckets {
fmt.Fprintf(w, `<Bucket><Name>%s</Name><CreationDate>2026-04-20T00:00:00Z</CreationDate></Bucket>`, bucket)
}
fmt.Fprint(w, `</Buckets></ListAllMyBucketsResult>`)
case strings.HasSuffix(r.URL.Path, "/") && r.URL.Query().Has("location"):
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">us-west-001</LocationConstraint>`)
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, `<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Name>%s</Name><IsTruncated>false</IsTruncated>`, bucket)
for _, object := range objects[bucket] {
fmt.Fprintf(w, `<Contents><Key>%s</Key><LastModified>%s</LastModified><Size>%d</Size></Contents>`,
object.key,
object.lastModified.UTC().Format(time.RFC3339),
object.size,
)
}
fmt.Fprint(w, `</ListBucketResult>`)
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)
}
}

View File

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

View File

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