test(soteria): cover policy handlers and runtime helpers
This commit is contained in:
parent
d975d9076f
commit
7f44730f58
172
internal/server/policy_http_handlers_test.go
Normal file
172
internal/server/policy_http_handlers_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
185
internal/server/policy_runtime_test.go
Normal file
185
internal/server/policy_runtime_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user