diff --git a/internal/server/policy_http_handlers_test.go b/internal/server/policy_http_handlers_test.go new file mode 100644 index 0000000..8d176a6 --- /dev/null +++ b/internal/server/policy_http_handlers_test.go @@ -0,0 +1,172 @@ +package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "scm.bstein.dev/bstein/soteria/internal/api" + "scm.bstein.dev/bstein/soteria/internal/config" +) + +type policyTestKubeClient struct { + *fakeKubeClient + saveErr error +} + +func (k *policyTestKubeClient) SaveSecretData(ctx context.Context, namespace, secretName, key string, value []byte, labels map[string]string) error { + if k.saveErr != nil { + return k.saveErr + } + return k.fakeKubeClient.SaveSecretData(ctx, namespace, secretName, key, value, labels) +} + +func newPolicyHandlerServer(client kubeClient) *Server { + srv := &Server{ + cfg: &config.Config{ + AuthRequired: false, + BackupDriver: "restic", + Namespace: "maintenance", + PolicySecretName: "soteria-policies", + }, + client: client, + longhorn: &fakeLonghornClient{}, + metrics: newTelemetry(), + policies: map[string]api.BackupPolicy{}, + } + srv.handler = http.HandlerFunc(srv.route) + return srv +} + +func TestHandlePoliciesRejectsUnsupportedAndInvalidRequests(t *testing.T) { + srv := newPolicyHandlerServer(&policyTestKubeClient{fakeKubeClient: &fakeKubeClient{}}) + + t.Run("method not allowed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, "/v1/policies", nil) + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d: %s", res.Code, res.Body.String()) + } + }) + + t.Run("invalid json", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/v1/policies", strings.NewReader(`{"namespace":`)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusBadRequest || !strings.Contains(res.Body.String(), "invalid JSON") { + t.Fatalf("expected invalid JSON 400, got %d: %s", res.Code, res.Body.String()) + } + }) + + t.Run("validation error", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/v1/policies", strings.NewReader(`{"namespace":"","interval_hours":2}`)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusBadRequest || !strings.Contains(res.Body.String(), "namespace is required") { + t.Fatalf("expected namespace validation error, got %d: %s", res.Code, res.Body.String()) + } + }) +} + +func TestHandlePoliciesSurfacesPersistFailure(t *testing.T) { + srv := newPolicyHandlerServer(&policyTestKubeClient{ + fakeKubeClient: &fakeKubeClient{}, + saveErr: errors.New("save exploded"), + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/policies", strings.NewReader(`{"namespace":"apps","interval_hours":2}`)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusBadRequest || !strings.Contains(res.Body.String(), "save exploded") { + t.Fatalf("expected persist failure surfaced as 400, got %d: %s", res.Code, res.Body.String()) + } + if len(srv.policies) != 0 { + t.Fatalf("expected failed upsert to roll back in-memory policies, got %#v", srv.policies) + } +} + +func TestHandlePolicyByIDRejectsBadRequestsAndMissingPolicies(t *testing.T) { + srv := newPolicyHandlerServer(&policyTestKubeClient{fakeKubeClient: &fakeKubeClient{}}) + + t.Run("method not allowed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/policies/apps__data", nil) + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d: %s", res.Code, res.Body.String()) + } + }) + + t.Run("missing id", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/v1/policies/", nil) + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusBadRequest || !strings.Contains(res.Body.String(), "policy id is required") { + t.Fatalf("expected missing id error, got %d: %s", res.Code, res.Body.String()) + } + }) + + t.Run("invalid escaped id", func(t *testing.T) { + req := &http.Request{ + Method: http.MethodDelete, + URL: &url.URL{Path: "/v1/policies/%zz"}, + Header: make(http.Header), + } + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusBadRequest || !strings.Contains(res.Body.String(), "invalid policy id") { + t.Fatalf("expected invalid escaped id error, got %d: %s", res.Code, res.Body.String()) + } + }) + + t.Run("not found", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/v1/policies/apps__data", nil) + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusNotFound || !strings.Contains(res.Body.String(), "policy not found") { + t.Fatalf("expected not found error, got %d: %s", res.Code, res.Body.String()) + } + }) +} + +func TestHandlePolicyByIDSurfacesPersistFailure(t *testing.T) { + srv := newPolicyHandlerServer(&policyTestKubeClient{ + fakeKubeClient: &fakeKubeClient{}, + saveErr: errors.New("delete save exploded"), + }) + srv.policies["apps__data"] = api.BackupPolicy{ + ID: "apps__data", + Namespace: "apps", + PVC: "data", + IntervalHours: 6, + Enabled: true, + Dedupe: true, + } + + req := httptest.NewRequest(http.MethodDelete, "/v1/policies/apps__data", nil) + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusBadGateway || !strings.Contains(res.Body.String(), "delete save exploded") { + t.Fatalf("expected persist failure surfaced as 502, got %d: %s", res.Code, res.Body.String()) + } + if _, ok := srv.policies["apps__data"]; !ok { + t.Fatalf("expected failed delete to roll back in-memory policies") + } +} diff --git a/internal/server/policy_runtime_test.go b/internal/server/policy_runtime_test.go new file mode 100644 index 0000000..1c8acb3 --- /dev/null +++ b/internal/server/policy_runtime_test.go @@ -0,0 +1,185 @@ +package server + +import ( + "context" + "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) + } +}