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