soteria/internal/server/policy_runtime_test.go

254 lines
8.1 KiB
Go
Raw Permalink Normal View History

package server
import (
"context"
"math"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/soteria/internal/api"
"scm.bstein.dev/bstein/soteria/internal/config"
)
func TestListPoliciesSortsByNamespacePVCAndID(t *testing.T) {
srv := &Server{
policies: map[string]api.BackupPolicy{
"ops__db": {ID: "ops__db", Namespace: "ops", PVC: "db"},
"apps__cache": {ID: "apps__cache", Namespace: "apps", PVC: "cache"},
"apps__all": {ID: "apps__all", Namespace: "apps", PVC: ""},
"apps__z-extra": {ID: "apps__z-extra", Namespace: "apps", PVC: "z-extra"},
},
}
policies := srv.listPolicies()
if len(policies) != 4 {
t.Fatalf("expected four policies, got %#v", policies)
}
gotOrder := []string{policies[0].ID, policies[1].ID, policies[2].ID, policies[3].ID}
wantOrder := []string{"apps__all", "apps__cache", "apps__z-extra", "ops__db"}
for i := range wantOrder {
if gotOrder[i] != wantOrder[i] {
t.Fatalf("expected sorted order %v, got %v", wantOrder, gotOrder)
}
}
}
func TestUpsertPolicyAppliesDefaultsAndPreservesCreatedAt(t *testing.T) {
srv := newPolicyHandlerServer(&policyTestKubeClient{fakeKubeClient: &fakeKubeClient{}})
created, err := srv.upsertPolicy(context.Background(), api.BackupPolicyUpsertRequest{
Namespace: "apps",
PVC: "data",
IntervalHours: 0,
})
if err != nil {
t.Fatalf("upsertPolicy create: %v", err)
}
if created.IntervalHours != defaultPolicyHours {
t.Fatalf("expected default interval %.0f, got %#v", defaultPolicyHours, created)
}
if !created.Dedupe || created.KeepLast != 0 || !created.Enabled {
t.Fatalf("expected default dedupe/keep_last/enabled values, got %#v", created)
}
if created.CreatedAt == "" || created.UpdatedAt == "" {
t.Fatalf("expected timestamps to be populated, got %#v", created)
}
disabled := false
dedupe := false
keepLast := 4
updated, err := srv.upsertPolicy(context.Background(), api.BackupPolicyUpsertRequest{
Namespace: "apps",
PVC: "data",
IntervalHours: 12,
Enabled: &disabled,
Dedupe: &dedupe,
KeepLast: &keepLast,
})
if err != nil {
t.Fatalf("upsertPolicy update: %v", err)
}
if updated.CreatedAt != created.CreatedAt {
t.Fatalf("expected CreatedAt to be preserved, create=%q update=%q", created.CreatedAt, updated.CreatedAt)
}
if updated.IntervalHours != 12 || updated.Enabled || updated.Dedupe || updated.KeepLast != 4 {
t.Fatalf("expected updated policy values to stick, got %#v", updated)
}
}
func TestUpsertPolicyRejectsInvalidInputs(t *testing.T) {
srv := newPolicyHandlerServer(&policyTestKubeClient{fakeKubeClient: &fakeKubeClient{}})
testCases := []struct {
name string
req api.BackupPolicyUpsertRequest
want string
}{
{
name: "invalid namespace",
req: api.BackupPolicyUpsertRequest{Namespace: "Apps", IntervalHours: 1},
want: "namespace must be a valid Kubernetes DNS-1123 label",
},
{
name: "invalid pvc",
req: api.BackupPolicyUpsertRequest{Namespace: "apps", PVC: "Bad_PVC", IntervalHours: 1},
want: "pvc must be a valid Kubernetes DNS-1123 label",
},
{
name: "interval too high",
req: api.BackupPolicyUpsertRequest{Namespace: "apps", IntervalHours: maxPolicyIntervalHrs + 1},
want: "interval_hours must be <=",
},
{
name: "negative keep last",
req: api.BackupPolicyUpsertRequest{Namespace: "apps", IntervalHours: 1, KeepLast: intPtr(-1)},
want: "keep_last must be >=",
},
}
for _, tc := range testCases {
if _, err := srv.upsertPolicy(context.Background(), tc.req); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: expected error containing %q, got %v", tc.name, tc.want, err)
}
}
}
func TestDeletePolicyHandlesEmptyMissingAndSuccess(t *testing.T) {
srv := &Server{
policies: map[string]api.BackupPolicy{
"apps__data": {ID: "apps__data", Namespace: "apps", PVC: "data"},
},
}
removed, err := srv.deletePolicy(context.Background(), "")
if err != nil || removed {
t.Fatalf("expected empty id delete to no-op, removed=%v err=%v", removed, err)
}
removed, err = srv.deletePolicy(context.Background(), "missing")
if err != nil || removed {
t.Fatalf("expected missing id delete to no-op, removed=%v err=%v", removed, err)
}
srv.client = &policyTestKubeClient{fakeKubeClient: &fakeKubeClient{}}
srv.cfg = &config.Config{Namespace: "maintenance", PolicySecretName: "soteria-policies"}
removed, err = srv.deletePolicy(context.Background(), "apps__data")
if err != nil || !removed {
t.Fatalf("expected existing id delete to succeed, removed=%v err=%v", removed, err)
}
}
func TestPolicySliceFromMapAndBackupDueHelpers(t *testing.T) {
slice := policySliceFromMap(map[string]api.BackupPolicy{
"b": {ID: "b"},
"a": {ID: "a"},
})
if len(slice) != 2 || slice[0].ID != "a" || slice[1].ID != "b" {
t.Fatalf("expected sorted policy slice by id, got %#v", slice)
}
if !backupDue("", 1) {
t.Fatalf("expected empty backup timestamp to be due")
}
if !backupDue("not-a-time", 1) {
t.Fatalf("expected invalid backup timestamp to be due")
}
if !backupDue(time.Now().UTC().Add(-48*time.Hour).Format(time.RFC3339), 1) {
t.Fatalf("expected old backup timestamp to be due")
}
if backupDue(time.Now().UTC().Add(-30*time.Minute).Format(time.RFC3339), 2) {
t.Fatalf("expected recent backup timestamp to not be due")
}
if backupDue(time.Now().UTC().Add(-30*time.Minute).Format(time.RFC3339), 0) {
t.Fatalf("expected default interval fallback to keep recent backup not due")
}
}
func TestLoadPoliciesRejectsInvalidDocuments(t *testing.T) {
srv := &Server{
cfg: &config.Config{
Namespace: "maintenance",
PolicySecretName: "soteria-policies",
},
client: &fakeKubeClient{
secretData: map[string][]byte{
policySecretKey: []byte(`{"policies":[`),
},
},
policies: map[string]api.BackupPolicy{},
}
err := srv.loadPolicies(context.Background())
if err == nil || !strings.Contains(err.Error(), "decode policy document") {
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)
}
}