test(soteria): finish per-file coverage floor

This commit is contained in:
codex 2026-04-20 21:24:27 -03:00
parent 683014443d
commit f7a08eb420
6 changed files with 371 additions and 3 deletions

View File

@ -3,9 +3,11 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"net"
"net/http" "net/http"
"os" "os"
"testing" "testing"
"time"
"scm.bstein.dev/bstein/soteria/internal/config" "scm.bstein.dev/bstein/soteria/internal/config"
"scm.bstein.dev/bstein/soteria/internal/k8s" "scm.bstein.dev/bstein/soteria/internal/k8s"
@ -228,6 +230,63 @@ func TestRunLogsShutdownAndLateServerErrorsWithoutFailing(t *testing.T) {
} }
} }
func TestDefaultMainHooksExecute(t *testing.T) {
restore := swapMainTestHooks()
defer restore()
cfg := &config.Config{
ListenAddr: "127.0.0.1:0",
MetricsRefreshInterval: time.Hour,
PolicyEvalInterval: time.Hour,
BackupMaxAge: time.Hour,
}
app := newServerFn(cfg, &k8s.Client{}, &longhorn.Client{})
if app == nil || app.Handler() == nil {
t.Fatalf("expected default server hook to build an application server")
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve local address: %v", err)
}
addr := ln.Addr().String()
_ = ln.Close()
httpServer := &http.Server{
Addr: addr,
Handler: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}),
}
errCh := make(chan error, 1)
go func() {
errCh <- listenAndServeFn(httpServer)
}()
deadline := time.Now().Add(2 * time.Second)
for {
conn, dialErr := net.DialTimeout("tcp", addr, 50*time.Millisecond)
if dialErr == nil {
_ = conn.Close()
break
}
if time.Now().After(deadline) {
t.Fatalf("default listen hook did not start server: %v", dialErr)
}
time.Sleep(10 * time.Millisecond)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := shutdownServerFn(httpServer, ctx); err != nil {
t.Fatalf("expected default shutdown hook to succeed, got %v", err)
}
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) {
t.Fatalf("expected server closed or nil from default hooks, got %v", err)
}
}
func swapMainTestHooks() func() { func swapMainTestHooks() func() {
originalLoad := loadConfigFn originalLoad := loadConfigFn
originalK8s := newK8sClientFn originalK8s := newK8sClientFn

View File

@ -175,6 +175,25 @@ func TestLoadSupportsResticAndB2Overrides(t *testing.T) {
} }
} }
func TestLoadInfersB2EnablementAndSecretNamespaceDefault(t *testing.T) {
clearConfigEnv(t)
withEnv(t, "SOTERIA_NAMESPACE", "atlas")
withEnv(t, "SOTERIA_B2_SECRET_NAME", "b2-creds")
withEnv(t, "SOTERIA_B2_ACCESS_KEY_ID", "abc")
withEnv(t, "SOTERIA_B2_SECRET_ACCESS_KEY", "def")
cfg, err := Load()
if err != nil {
t.Fatalf("load config with inferred b2 enablement: %v", err)
}
if !cfg.B2Enabled {
t.Fatalf("expected B2 enablement to be inferred from secret config")
}
if cfg.B2SecretNamespace != "atlas" {
t.Fatalf("expected B2 secret namespace to default to service namespace, got %#v", cfg.B2SecretNamespace)
}
}
func TestLoadRejectsInvalidConfigurations(t *testing.T) { func TestLoadRejectsInvalidConfigurations(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
@ -196,6 +215,15 @@ func TestLoadRejectsInvalidConfigurations(t *testing.T) {
}, },
substr: "SOTERIA_JOB_NODE_SELECTOR is invalid", substr: "SOTERIA_JOB_NODE_SELECTOR is invalid",
}, },
{
name: "restic invalid repository path",
env: map[string]string{
"SOTERIA_BACKUP_DRIVER": "restic",
"SOTERIA_RESTIC_REPOSITORY": "s3:https://repo/../bad",
"SOTERIA_RESTIC_SECRET_NAME": "restic-creds",
},
substr: "SOTERIA_RESTIC_REPOSITORY contains invalid path segments",
},
{ {
name: "unsupported driver", name: "unsupported driver",
env: map[string]string{ env: map[string]string{
@ -240,6 +268,15 @@ func TestLoadRejectsInvalidConfigurations(t *testing.T) {
}, },
substr: "SOTERIA_B2_SCAN_INTERVAL_SECONDS must be greater than zero", substr: "SOTERIA_B2_SCAN_INTERVAL_SECONDS must be greater than zero",
}, },
{
name: "invalid b2 scan timeout",
env: map[string]string{
"SOTERIA_B2_ENABLED": "true",
"SOTERIA_B2_ENDPOINT": "https://s3.example.invalid",
"SOTERIA_B2_SCAN_TIMEOUT_SECONDS": "0",
},
substr: "SOTERIA_B2_SCAN_TIMEOUT_SECONDS must be greater than zero",
},
{ {
name: "invalid policy eval interval", name: "invalid policy eval interval",
env: map[string]string{ env: map[string]string{
@ -296,6 +333,9 @@ func TestEnvAndParsingHelpers(t *testing.T) {
if selector := parseNodeSelector("role=worker, hardware=rpi5"); selector["role"] != "worker" || selector["hardware"] != "rpi5" { if selector := parseNodeSelector("role=worker, hardware=rpi5"); selector["role"] != "worker" || selector["hardware"] != "rpi5" {
t.Fatalf("unexpected selector parse: %#v", selector) t.Fatalf("unexpected selector parse: %#v", selector)
} }
if selector := parseNodeSelector("role=worker, , hardware=rpi5"); selector["role"] != "worker" || selector["hardware"] != "rpi5" {
t.Fatalf("expected empty selector segments to be ignored, got %#v", selector)
}
if parseNodeSelector("role=") != nil { if parseNodeSelector("role=") != nil {
t.Fatalf("expected invalid selector to return nil") t.Fatalf("expected invalid selector to return nil")
} }

View File

@ -0,0 +1,115 @@
package k8s
import (
"strings"
"testing"
"scm.bstein.dev/bstein/soteria/internal/api"
"scm.bstein.dev/bstein/soteria/internal/config"
)
func TestBuildBackupJobAppliesSelectorsServiceAccountAndMetadata(t *testing.T) {
cfg := &config.Config{
ResticImage: "restic/restic:latest",
ResticRepository: "s3:https://repo/root",
ResticBackupArgs: []string{"--exclude", "*.tmp"},
ResticForgetArgs: []string{"--keep-daily", "7", "--prune"},
S3Endpoint: "https://s3.us-west-001.backblazeb2.com",
S3Region: "us-west-001",
JobTTLSeconds: 3600,
JobNodeSelector: map[string]string{"hardware": "rpi5"},
WorkerServiceAccount: "soteria-worker",
}
keepLast := 3
req := api.BackupRequest{
Namespace: "apps",
PVC: "data",
Tags: []string{"nightly"},
KeepLast: &keepLast,
}
job := buildBackupJob(cfg, req, "backup-job", "restic-secret", "s3:https://repo/root/isolated/apps/data", false, keepLast)
if job.Name != "backup-job" || job.Namespace != "apps" {
t.Fatalf("expected backup job identity, got %#v", job.ObjectMeta)
}
if job.Labels[labelPVC] != "data" || job.Annotations[annotationDedupeEnabled] != "false" || job.Annotations[annotationKeepLast] != "3" {
t.Fatalf("expected pvc/dedupe/keep-last metadata, got labels=%#v annotations=%#v", job.Labels, job.Annotations)
}
if got := job.Spec.Template.Spec.NodeSelector["hardware"]; got != "rpi5" {
t.Fatalf("expected node selector to be applied, got %#v", job.Spec.Template.Spec.NodeSelector)
}
if got := job.Spec.Template.Spec.ServiceAccountName; got != "soteria-worker" {
t.Fatalf("expected worker service account, got %q", got)
}
if len(job.Spec.Template.Spec.Containers) != 1 {
t.Fatalf("expected one backup container, got %#v", job.Spec.Template.Spec.Containers)
}
container := job.Spec.Template.Spec.Containers[0]
if container.Name != "restic" || container.Image != "restic/restic:latest" {
t.Fatalf("expected restic container, got %#v", container)
}
if len(container.Args) != 1 || !strings.Contains(container.Args[0], "restic backup /data") || !strings.Contains(container.Args[0], "--keep-last 3") {
t.Fatalf("expected backup command payload, got %#v", container.Args)
}
if len(job.Spec.Template.Spec.Volumes) != 2 || job.Spec.Template.Spec.Volumes[0].PersistentVolumeClaim == nil {
t.Fatalf("expected pvc + cache volumes, got %#v", job.Spec.Template.Spec.Volumes)
}
if got := *job.Spec.BackoffLimit; got != 0 {
t.Fatalf("expected zero backoff limit, got %d", got)
}
if got := *job.Spec.TTLSecondsAfterFinished; got != 3600 {
t.Fatalf("expected ttl from config, got %d", got)
}
}
func TestBuildRestoreJobCoversDefaultAndTargetPVCRestoreShapes(t *testing.T) {
cfg := &config.Config{
ResticImage: "restic/restic:latest",
ResticRepository: "s3:https://repo/root",
S3Endpoint: "https://s3.us-west-001.backblazeb2.com",
S3Region: "us-west-001",
JobTTLSeconds: 1800,
JobNodeSelector: map[string]string{"hardware": "rpi5"},
WorkerServiceAccount: "soteria-worker",
}
defaultJob := buildRestoreJob(cfg, api.RestoreTestRequest{
Namespace: "apps",
}, "restore-job", "restic-secret", "latest", "s3:https://repo/root")
if got := defaultJob.Spec.Template.Spec.Volumes[0].EmptyDir; got == nil {
t.Fatalf("expected default restore target to use emptyDir, got %#v", defaultJob.Spec.Template.Spec.Volumes[0])
}
if got := defaultJob.Spec.Template.Spec.ServiceAccountName; got != "soteria-worker" {
t.Fatalf("expected worker service account, got %q", got)
}
if got := defaultJob.Spec.Template.Spec.NodeSelector["hardware"]; got != "rpi5" {
t.Fatalf("expected node selector to be applied, got %#v", defaultJob.Spec.Template.Spec.NodeSelector)
}
targetPVCJob := buildRestoreJob(cfg, api.RestoreTestRequest{
Namespace: "apps",
TargetPVC: "restore-data",
}, "restore-job", "restic-secret", "snapshot-1", "s3:https://repo/root")
if targetPVCJob.Labels[labelPVC] != "restore-data" {
t.Fatalf("expected restore pvc label, got %#v", targetPVCJob.Labels)
}
if claim := targetPVCJob.Spec.Template.Spec.Volumes[0].PersistentVolumeClaim; claim == nil || claim.ClaimName != "restore-data" {
t.Fatalf("expected target pvc volume mount, got %#v", targetPVCJob.Spec.Template.Spec.Volumes[0])
}
if len(targetPVCJob.Spec.Template.Spec.Containers) != 1 || !strings.Contains(targetPVCJob.Spec.Template.Spec.Containers[0].Args[0], "restic restore snapshot-1") {
t.Fatalf("expected restore command payload, got %#v", targetPVCJob.Spec.Template.Spec.Containers)
}
}
func TestRestoreCommandAndInt32PtrHelpers(t *testing.T) {
command := restoreCommand("snapshot-42")
if !strings.Contains(command, "restic restore snapshot-42") || !strings.Contains(command, "cp -a /cache/restore") {
t.Fatalf("expected restore shell command, got %q", command)
}
value := int32Ptr(17)
if value == nil || *value != 17 {
t.Fatalf("expected int32 pointer helper to preserve value, got %#v", value)
}
}

View File

@ -19,6 +19,14 @@ func TestLoadSecretDataCoversMissingSecretValueAndCopy(t *testing.T) {
ObjectMeta: metav1.ObjectMeta{Name: "filled", Namespace: "atlas"}, ObjectMeta: metav1.ObjectMeta{Name: "filled", Namespace: "atlas"},
Data: map[string][]byte{"token": []byte("atlas-secret")}, Data: map[string][]byte{"token": []byte("atlas-secret")},
}, },
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "missing-key", Namespace: "atlas"},
Data: map[string][]byte{"other": []byte("atlas-secret")},
},
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "empty-value", Namespace: "atlas"},
Data: map[string][]byte{"token": {}},
},
&corev1.Secret{ &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "empty", Namespace: "atlas"}, ObjectMeta: metav1.ObjectMeta{Name: "empty", Namespace: "atlas"},
}, },
@ -34,6 +42,16 @@ func TestLoadSecretDataCoversMissingSecretValueAndCopy(t *testing.T) {
t.Fatalf("expected empty secret key to return nil, got %q %v", string(value), err) t.Fatalf("expected empty secret key to return nil, got %q %v", string(value), err)
} }
value, err = client.LoadSecretData(context.Background(), "atlas", "missing-key", "token")
if err != nil || value != nil {
t.Fatalf("expected missing data key to return nil, got %q %v", string(value), err)
}
value, err = client.LoadSecretData(context.Background(), "atlas", "empty-value", "token")
if err != nil || value != nil {
t.Fatalf("expected empty data value to return nil, got %q %v", string(value), err)
}
value, err = client.LoadSecretData(context.Background(), "atlas", "filled", "token") value, err = client.LoadSecretData(context.Background(), "atlas", "filled", "token")
if err != nil || string(value) != "atlas-secret" { if err != nil || string(value) != "atlas-secret" {
t.Fatalf("expected copied secret value, got %q %v", string(value), err) t.Fatalf("expected copied secret value, got %q %v", string(value), err)
@ -96,6 +114,38 @@ func TestSaveSecretDataCreatesAndUpdatesSecrets(t *testing.T) {
} }
} }
func TestSaveSecretDataInitializesNilDataAndLabelsOnExistingSecret(t *testing.T) {
client := &Client{Clientset: k8sfake.NewSimpleClientset()}
if _, err := client.Clientset.CoreV1().Secrets("atlas").Create(context.Background(), &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "restic-usage", Namespace: "atlas"},
}, metav1.CreateOptions{}); err != nil {
t.Fatalf("seed secret: %v", err)
}
seeded, err := client.Clientset.CoreV1().Secrets("atlas").Get(context.Background(), "restic-usage", metav1.GetOptions{})
if err != nil {
t.Fatalf("get seeded secret: %v", err)
}
seeded.ResourceVersion = "1"
if _, err := client.Clientset.CoreV1().Secrets("atlas").Update(context.Background(), seeded, metav1.UpdateOptions{}); err != nil {
t.Fatalf("prime seeded secret resource version: %v", err)
}
if err := client.SaveSecretData(context.Background(), "atlas", "restic-usage", "usage.json", []byte("value"), map[string]string{"app": "soteria"}); err != nil {
t.Fatalf("save secret data with nil data/labels: %v", err)
}
updated, err := client.Clientset.CoreV1().Secrets("atlas").Get(context.Background(), "restic-usage", metav1.GetOptions{})
if err != nil {
t.Fatalf("get updated secret: %v", err)
}
if string(updated.Data["usage.json"]) != "value" {
t.Fatalf("expected updated secret payload, got %#v", updated.Data)
}
if updated.Labels["app"] != "soteria" {
t.Fatalf("expected labels to be initialized, got %#v", updated.Labels)
}
}
func TestSaveSecretDataWrapsGetAndWriteErrors(t *testing.T) { func TestSaveSecretDataWrapsGetAndWriteErrors(t *testing.T) {
t.Run("get error", func(t *testing.T) { t.Run("get error", func(t *testing.T) {
clientset := k8sfake.NewSimpleClientset() clientset := k8sfake.NewSimpleClientset()

View File

@ -112,6 +112,15 @@ func TestListBoundPVCsAndExistsCoversFilteringSortingAndCapacityFallback(t *test
if err != nil || exists { if err != nil || exists {
t.Fatalf("expected pvc to be missing, got %v %v", exists, err) t.Fatalf("expected pvc to be missing, got %v %v", exists, err)
} }
clientset := k8sfake.NewSimpleClientset()
clientset.PrependReactor("list", "persistentvolumeclaims", func(action k8stesting.Action) (bool, runtime.Object, error) {
return true, nil, apierrors.NewForbidden(schema.GroupResource{Resource: "persistentvolumeclaims"}, "", nil)
})
client = &Client{Clientset: clientset}
if _, err := client.ListBoundPVCs(context.Background()); err == nil {
t.Fatalf("expected wrapped pvc list error")
}
} }
func TestPersistentVolumeClaimExistsWrapsUnexpectedErrors(t *testing.T) { func TestPersistentVolumeClaimExistsWrapsUnexpectedErrors(t *testing.T) {

View File

@ -8,6 +8,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"scm.bstein.dev/bstein/soteria/internal/api" "scm.bstein.dev/bstein/soteria/internal/api"
"scm.bstein.dev/bstein/soteria/internal/config" "scm.bstein.dev/bstein/soteria/internal/config"
@ -191,6 +192,100 @@ func TestHandleBackupsRejectsInvalidRequestsAndBackendErrors(t *testing.T) {
}) })
} }
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) { func TestHandleBackupAndNamespaceBackupValidationPaths(t *testing.T) {
t.Run("backup request validation", func(t *testing.T) { t.Run("backup request validation", func(t *testing.T) {
srv := newBackupTestServer( srv := newBackupTestServer(