diff --git a/internal/server/auth_support_test.go b/internal/server/auth_support_test.go new file mode 100644 index 0000000..34ca400 --- /dev/null +++ b/internal/server/auth_support_test.go @@ -0,0 +1,126 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "scm.bstein.dev/bstein/soteria/internal/config" +) + +func TestAuthorizeCoversAuthModesAndGroupChecks(t *testing.T) { + t.Run("auth disabled", func(t *testing.T) { + srv := &Server{cfg: &config.Config{AuthRequired: false}} + req := httptest.NewRequest(http.MethodGet, "/v1/inventory", nil) + identity, status, err := srv.authorize(req) + if err != nil || status != http.StatusOK || identity.Authenticated { + t.Fatalf("expected auth-disabled request to pass anonymously, got identity=%#v status=%d err=%v", identity, status, err) + } + }) + + t.Run("bearer token", func(t *testing.T) { + srv := &Server{cfg: &config.Config{ + AuthRequired: true, + AuthBearerTokens: []string{"atlas-secret"}, + }} + req := httptest.NewRequest(http.MethodGet, "/v1/inventory", nil) + req.Header.Set("Authorization", "Bearer atlas-secret") + identity, status, err := srv.authorize(req) + if err != nil || status != http.StatusOK || identity.User != "service-token" { + t.Fatalf("expected bearer token auth, got identity=%#v status=%d err=%v", identity, status, err) + } + }) + + t.Run("forwarded headers without group restriction", func(t *testing.T) { + srv := &Server{cfg: &config.Config{AuthRequired: true}} + req := httptest.NewRequest(http.MethodGet, "/v1/inventory", nil) + req.Header.Set("X-Forwarded-User", "brad") + req.Header.Set("X-Forwarded-Email", "brad@bstein.dev") + req.Header.Set("X-Forwarded-Groups", "/ops;/dev") + identity, status, err := srv.authorize(req) + if err != nil || status != http.StatusOK || identity.User != "brad" || len(identity.Groups) != 2 { + t.Fatalf("expected forwarded header auth, got identity=%#v status=%d err=%v", identity, status, err) + } + }) + + t.Run("missing identity", func(t *testing.T) { + srv := &Server{cfg: &config.Config{AuthRequired: true}} + req := httptest.NewRequest(http.MethodGet, "/v1/inventory", nil) + _, status, err := srv.authorize(req) + if err == nil || status != http.StatusUnauthorized { + t.Fatalf("expected unauthorized missing identity, got status=%d err=%v", status, err) + } + }) + + t.Run("group allowed", func(t *testing.T) { + srv := &Server{cfg: &config.Config{ + AuthRequired: true, + AllowedGroups: []string{"admin", "ops"}, + }} + req := httptest.NewRequest(http.MethodGet, "/v1/inventory", nil) + req.Header.Set("X-Auth-Request-User", "brad") + req.Header.Set("X-Auth-Request-Groups", "/dev,/ops") + identity, status, err := srv.authorize(req) + if err != nil || status != http.StatusOK || identity.User != "brad" { + t.Fatalf("expected allowed group auth, got identity=%#v status=%d err=%v", identity, status, err) + } + }) + + t.Run("group forbidden", func(t *testing.T) { + srv := &Server{cfg: &config.Config{ + AuthRequired: true, + AllowedGroups: []string{"admin"}, + }} + req := httptest.NewRequest(http.MethodGet, "/v1/inventory", nil) + req.Header.Set("X-Forwarded-User", "brad") + req.Header.Set("X-Forwarded-Groups", "/dev") + _, status, err := srv.authorize(req) + if err == nil || status != http.StatusForbidden { + t.Fatalf("expected forbidden group failure, got status=%d err=%v", status, err) + } + }) +} + +func TestRequesterAndAuthzHelpers(t *testing.T) { + ctx := context.WithValue(context.Background(), authContextKey, authIdentity{ + Authenticated: true, + User: "brad", + Email: "brad@bstein.dev", + }) + if got := currentRequester(ctx); got != "brad" { + t.Fatalf("expected requester user brad, got %q", got) + } + + ctx = context.WithValue(context.Background(), authContextKey, authIdentity{ + Authenticated: true, + Email: "brad@bstein.dev", + }) + if got := currentRequester(ctx); got != "brad@bstein.dev" { + t.Fatalf("expected requester email, got %q", got) + } + + ctx = context.WithValue(context.Background(), authContextKey, authIdentity{Authenticated: true}) + if got := currentRequester(ctx); got != "authenticated" { + t.Fatalf("expected generic authenticated requester, got %q", got) + } + + if got := currentRequester(context.Background()); got != "anonymous" { + t.Fatalf("expected anonymous requester, got %q", got) + } + + if got := authzReason(http.StatusUnauthorized, errSentinel); got != "unauthenticated" { + t.Fatalf("expected unauthenticated reason, got %q", got) + } + if got := authzReason(http.StatusForbidden, errSentinel); got != "forbidden_group" { + t.Fatalf("expected forbidden reason, got %q", got) + } + if got := authzReason(http.StatusBadGateway, errSentinel); got != "error" { + t.Fatalf("expected generic error reason, got %q", got) + } + if got := authzReason(http.StatusOK, nil); got != "unknown" { + t.Fatalf("expected unknown nil-error reason, got %q", got) + } +} + +var errSentinel = http.ErrAbortHandler diff --git a/internal/server/backup_handlers_test.go b/internal/server/backup_handlers_test.go new file mode 100644 index 0000000..32a837c --- /dev/null +++ b/internal/server/backup_handlers_test.go @@ -0,0 +1,376 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "scm.bstein.dev/bstein/soteria/internal/api" + "scm.bstein.dev/bstein/soteria/internal/config" + "scm.bstein.dev/bstein/soteria/internal/k8s" + "scm.bstein.dev/bstein/soteria/internal/longhorn" +) + +func assertErrorResponseContains(t *testing.T, res *httptest.ResponseRecorder, wantStatus int, want string) { + t.Helper() + if res.Code != wantStatus { + t.Fatalf("expected %d, got %d: %s", wantStatus, res.Code, res.Body.String()) + } + + var payload map[string]string + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode error response: %v", err) + } + + if got := payload["error"]; !strings.Contains(got, want) { + t.Fatalf("expected error containing %q, got %q", want, got) + } +} + +type backupTestKubeClient struct { + *restoreTestKubeClient + createBackupErr error + listBackupJobsPVCErr error +} + +func (k *backupTestKubeClient) CreateBackupJob(ctx context.Context, cfg *config.Config, req api.BackupRequest) (string, string, error) { + if k.createBackupErr != nil { + return "", "", k.createBackupErr + } + return k.restoreTestKubeClient.fakeKubeClient.CreateBackupJob(ctx, cfg, req) +} + +func (k *backupTestKubeClient) ListBackupJobsForPVC(ctx context.Context, namespace, pvc string) ([]k8s.BackupJobSummary, error) { + if k.listBackupJobsPVCErr != nil { + return nil, k.listBackupJobsPVCErr + } + return k.restoreTestKubeClient.fakeKubeClient.ListBackupJobsForPVC(ctx, namespace, pvc) +} + +type backupTestLonghornClient struct { + *restoreTestLonghornClient + listBackups []longhorn.Backup + listBackupsErr error + createSnapErr error + snapshotErr error +} + +func (l *backupTestLonghornClient) ListBackups(ctx context.Context, volumeName string) ([]longhorn.Backup, error) { + if l.listBackupsErr != nil { + return nil, l.listBackupsErr + } + if l.listBackups != nil { + return l.listBackups, nil + } + return l.restoreTestLonghornClient.fakeLonghornClient.ListBackups(ctx, volumeName) +} + +func (l *backupTestLonghornClient) CreateSnapshot(ctx context.Context, volume, name string, labels map[string]string) error { + if l.createSnapErr != nil { + return l.createSnapErr + } + return l.restoreTestLonghornClient.fakeLonghornClient.CreateSnapshot(ctx, volume, name, labels) +} + +func (l *backupTestLonghornClient) SnapshotBackup(ctx context.Context, volume, name string, labels map[string]string, backupMode string) (*longhorn.Volume, error) { + if l.snapshotErr != nil { + return nil, l.snapshotErr + } + return l.restoreTestLonghornClient.fakeLonghornClient.SnapshotBackup(ctx, volume, name, labels, backupMode) +} + +func newBackupTestServer(cfg *config.Config, client kubeClient, longhornClient longhornClient) *Server { + return newRestoreTestServer(cfg, client, longhornClient) +} + +func TestHandleInventoryRejectsUnsupportedMethodAndBackendError(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{ + fakeKubeClient: &fakeKubeClient{}, + listPVCsErr: errors.New("inventory exploded"), + }}, + &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, + ) + + req := httptest.NewRequest(http.MethodPost, "/v1/inventory", 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()) + } + + req = httptest.NewRequest(http.MethodGet, "/v1/inventory", nil) + res = httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + assertErrorResponseContains(t, res, http.StatusBadGateway, "inventory exploded") +} + +func TestHandleBackupsRejectsInvalidRequestsAndBackendErrors(t *testing.T) { + t.Run("invalid requests", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{ + fakeKubeClient: &fakeKubeClient{}, + }}, + &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, + ) + + req := httptest.NewRequest(http.MethodPost, "/v1/backups", 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()) + } + + req = httptest.NewRequest(http.MethodGet, "/v1/backups", nil) + res = httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + assertErrorResponseContains(t, res, http.StatusBadRequest, "namespace and pvc are required") + }) + + t.Run("resolve pvc error", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{ + fakeKubeClient: &fakeKubeClient{}, + resolveErr: errors.New("resolve backup pvc exploded"), + }}, + &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) + assertErrorResponseContains(t, res, http.StatusBadRequest, "resolve backup pvc exploded") + }) + + t.Run("longhorn backend error", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, + &backupTestLonghornClient{ + restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}, + listBackupsErr: errors.New("list backups exploded"), + }, + ) + req := httptest.NewRequest(http.MethodGet, "/v1/backups?namespace=apps&pvc=data", nil) + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + assertErrorResponseContains(t, res, http.StatusBadGateway, "list backups exploded") + }) + + t.Run("restic backend error", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "restic"}, + &backupTestKubeClient{ + restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}, + listBackupJobsPVCErr: errors.New("list jobs exploded"), + }, + &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) + assertErrorResponseContains(t, res, http.StatusBadGateway, "list jobs exploded") + }) + + t.Run("unsupported driver", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "mystery"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, + &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) + assertErrorResponseContains(t, res, http.StatusBadRequest, "unsupported backup driver") + }) +} + +func TestHandleBackupAndNamespaceBackupValidationPaths(t *testing.T) { + t.Run("backup request validation", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "restic"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, + &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, + ) + + testCases := []struct { + name string + body string + code int + want string + }{ + {name: "method", body: "", code: http.StatusMethodNotAllowed, want: "method not allowed"}, + {name: "invalid json", body: `{"namespace":`, code: http.StatusBadRequest, want: "invalid JSON"}, + {name: "missing namespace", body: `{"pvc":"data"}`, code: http.StatusBadRequest, want: "namespace and pvc are required"}, + {name: "invalid keep last", body: `{"namespace":"apps","pvc":"data","keep_last":-1}`, code: http.StatusBadRequest, want: "keep_last must be >="}, + } + + for _, tc := range testCases { + method := http.MethodPost + if tc.name == "method" { + method = http.MethodGet + } + req := httptest.NewRequest(method, "/v1/backup", strings.NewReader(tc.body)) + if method == http.MethodPost { + req.Header.Set("Content-Type", "application/json") + } + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + assertErrorResponseContains(t, res, tc.code, tc.want) + } + }) + + t.Run("namespace backup validation and backend", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "restic"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{ + fakeKubeClient: &fakeKubeClient{}, + listPVCsErr: errors.New("list namespace pvc exploded"), + }}, + &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, + ) + + testCases := []struct { + name string + body string + code int + want string + }{ + {name: "invalid json", body: `{"namespace":`, code: http.StatusBadRequest, want: "invalid JSON"}, + {name: "missing namespace", body: `{}`, code: http.StatusBadRequest, want: "namespace is required"}, + {name: "invalid keep last", body: `{"namespace":"apps","keep_last":-1}`, code: http.StatusBadRequest, want: "keep_last must be >="}, + {name: "invalid namespace", body: `{"namespace":"Bad_Ns"}`, code: http.StatusBadRequest, want: "namespace must be a valid Kubernetes DNS-1123 label"}, + {name: "backend error", body: `{"namespace":"apps"}`, code: http.StatusBadGateway, want: "list namespace pvc exploded"}, + } + + for _, tc := range testCases { + req := httptest.NewRequest(http.MethodPost, "/v1/backup/namespace", strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + assertErrorResponseContains(t, res, tc.code, tc.want) + } + }) +} + +func TestExecuteBackupAndStatusHelpers(t *testing.T) { + t.Run("validation error", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, + &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, + ) + _, result, err := srv.executeBackup(context.Background(), api.BackupRequest{}, "brad") + if result != "validation_error" || err == nil { + t.Fatalf("expected validation error, got result=%q err=%v", result, err) + } + }) + + t.Run("longhorn resolve error", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{ + fakeKubeClient: &fakeKubeClient{}, + resolveErr: errors.New("resolve backup pvc exploded"), + }}, + &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, + ) + _, result, err := srv.executeBackup(context.Background(), api.BackupRequest{Namespace: "apps", PVC: "data"}, "brad") + if result != "validation_error" || err == nil || !strings.Contains(err.Error(), "resolve backup pvc exploded") { + t.Fatalf("expected resolve error, got result=%q err=%v", result, err) + } + }) + + t.Run("longhorn snapshot error", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, + &backupTestLonghornClient{ + restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}, + createSnapErr: errors.New("create snapshot exploded"), + }, + ) + _, result, err := srv.executeBackup(context.Background(), api.BackupRequest{Namespace: "apps", PVC: "data"}, "brad") + if result != "backend_error" || err == nil || !strings.Contains(err.Error(), "create snapshot exploded") { + t.Fatalf("expected snapshot backend error, got result=%q err=%v", result, err) + } + }) + + t.Run("longhorn backup error", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, + &backupTestLonghornClient{ + restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}, + snapshotErr: errors.New("snapshot backup exploded"), + }, + ) + _, result, err := srv.executeBackup(context.Background(), api.BackupRequest{Namespace: "apps", PVC: "data"}, "brad") + if result != "backend_error" || err == nil || !strings.Contains(err.Error(), "snapshot backup exploded") { + t.Fatalf("expected backup backend error, got result=%q err=%v", result, err) + } + }) + + t.Run("restic backend error", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "restic"}, + &backupTestKubeClient{ + restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}, + createBackupErr: errors.New("create backup job exploded"), + }, + &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, + ) + _, result, err := srv.executeBackup(context.Background(), api.BackupRequest{Namespace: "apps", PVC: "data"}, "brad") + if result != "backend_error" || err == nil || !strings.Contains(err.Error(), "create backup job exploded") { + t.Fatalf("expected restic backend error, got result=%q err=%v", result, err) + } + }) + + t.Run("unsupported driver", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "mystery"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, + &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, + ) + _, result, err := srv.executeBackup(context.Background(), api.BackupRequest{Namespace: "apps", PVC: "data"}, "brad") + if result != "unsupported_driver" || err == nil || !strings.Contains(err.Error(), "unsupported backup driver") { + t.Fatalf("expected unsupported driver error, got result=%q err=%v", result, err) + } + }) + + t.Run("status helpers", func(t *testing.T) { + testCases := map[string]int{ + "validation_error": http.StatusBadRequest, + "unsupported_driver": http.StatusBadRequest, + "backend_error": http.StatusBadGateway, + "other": http.StatusInternalServerError, + } + for result, want := range testCases { + if got := backupStatusCode(result); got != want { + t.Fatalf("backupStatusCode(%q): expected %d, got %d", result, want, got) + } + } + + if got := namespaceResultStatus(true, 2, 2, 0); got != "dry_run" { + t.Fatalf("expected dry_run namespace result, got %q", got) + } + if got := namespaceResultStatus(false, 0, 0, 0); got != "empty" { + t.Fatalf("expected empty namespace result, got %q", got) + } + if got := namespaceResultStatus(false, 2, 2, 0); got != "success" { + t.Fatalf("expected success namespace result, got %q", got) + } + if got := namespaceResultStatus(false, 2, 0, 2); got != "failed" { + t.Fatalf("expected failed namespace result, got %q", got) + } + if got := namespaceResultStatus(false, 2, 1, 1); got != "partial" { + t.Fatalf("expected partial namespace result, got %q", got) + } + }) +}