From dd275d2e788c6a2966b8048a8654cb9290f34550 Mon Sep 17 00:00:00 2001 From: Noooste <83548733+Noooste@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:58:29 +0200 Subject: [PATCH] test: add comprehensive test suite for logging, request ID, and user management Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com> --- backend/internal/handlers/auth_test.go | 332 ++++++++ backend/internal/handlers/buckets_test.go | 419 ++++++++++ backend/internal/handlers/cluster_test.go | 176 ++++ backend/internal/handlers/health_test.go | 70 ++ backend/internal/handlers/monitoring_test.go | 215 +++++ .../internal/handlers/objects_helpers_test.go | 103 +++ backend/internal/handlers/objects_test.go | 768 ++++++++++++++++++ backend/internal/handlers/users_test.go | 367 +++++++++ backend/internal/middleware/logging_test.go | 166 ++++ backend/internal/middleware/requestid.go | 29 + backend/internal/middleware/requestid_test.go | 65 ++ .../services/admin_buckets_logging_test.go | 134 +++ backend/internal/services/admin_test.go | 565 +++++++++++++ backend/internal/services/s3_test.go | 331 ++++++++ backend/pkg/logger/redact_test.go | 32 + 15 files changed, 3772 insertions(+) create mode 100644 backend/internal/handlers/auth_test.go create mode 100644 backend/internal/handlers/buckets_test.go create mode 100644 backend/internal/handlers/cluster_test.go create mode 100644 backend/internal/handlers/health_test.go create mode 100644 backend/internal/handlers/monitoring_test.go create mode 100644 backend/internal/handlers/objects_helpers_test.go create mode 100644 backend/internal/handlers/objects_test.go create mode 100644 backend/internal/handlers/users_test.go create mode 100644 backend/internal/middleware/logging_test.go create mode 100644 backend/internal/middleware/requestid.go create mode 100644 backend/internal/middleware/requestid_test.go create mode 100644 backend/internal/services/admin_buckets_logging_test.go create mode 100644 backend/internal/services/admin_test.go create mode 100644 backend/internal/services/s3_test.go create mode 100644 backend/pkg/logger/redact_test.go diff --git a/backend/internal/handlers/auth_test.go b/backend/internal/handlers/auth_test.go new file mode 100644 index 0000000..66b0188 --- /dev/null +++ b/backend/internal/handlers/auth_test.go @@ -0,0 +1,332 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/config" + + "github.com/gofiber/fiber/v3" +) + +// newAuthTestService builds a real auth.Service with OIDC disabled. The JWT +// key is auto-generated, matching the production default. +func newAuthTestService(t *testing.T, admin config.AdminAuthConfig) *auth.Service { + t.Helper() + svc, err := auth.NewAuthService( + &config.AuthConfig{ + Admin: admin, + OIDC: config.OIDCConfig{Enabled: false}, + }, + &config.ServerConfig{}, + ) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + return svc +} + +// newAuthTestApp builds a bare Fiber app with the auth handler mounted. +// The admin and OIDC config are reflected both in cfg (for the handler) and +// in the auth service. +func newAuthTestApp(t *testing.T, cfg *config.Config) (*fiber.App, *AuthHandler) { + t.Helper() + svc := newAuthTestService(t, cfg.Auth.Admin) + h := NewAuthHandler(cfg, svc) + app := fiber.New() + app.Get("/auth/config", h.GetAuthConfig) + app.Post("/auth/login", h.LoginAdmin) + app.Get("/auth/me", h.GetMe) + return app, h +} + +func TestGetAuthConfig_AdminOnly(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "p"}, + OIDC: config.OIDCConfig{Enabled: false}, + }, + } + app, _ := newAuthTestApp(t, cfg) + req := httptest.NewRequest(http.MethodGet, "/auth/config", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body struct { + Admin struct { + Enabled bool `json:"enabled"` + } `json:"admin"` + OIDC struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + } `json:"oidc"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if !body.Admin.Enabled { + t.Error("admin.enabled = false, want true") + } + if body.OIDC.Enabled { + t.Error("oidc.enabled = true, want false") + } + if body.OIDC.Provider != "" { + t.Errorf("oidc.provider = %q, want empty", body.OIDC.Provider) + } +} + +func TestGetAuthConfig_OIDCOnly_WithExplicitProvider(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: false}, + OIDC: config.OIDCConfig{ + Enabled: false, // service init skipped (newAuthTestService disables OIDC); handler only reads flags + ProviderName: "Keycloak", + }, + }, + } + // Re-enable OIDC only on the cfg the handler sees — the service is still + // constructed with OIDC disabled above, which is fine because + // GetAuthConfig does not touch the service at all. + cfg.Auth.OIDC.Enabled = true + app, _ := newAuthTestApp(t, cfg) + + req := httptest.NewRequest(http.MethodGet, "/auth/config", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body struct { + OIDC struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + } `json:"oidc"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if !body.OIDC.Enabled { + t.Error("oidc.enabled = false, want true") + } + if body.OIDC.Provider != "Keycloak" { + t.Errorf("oidc.provider = %q, want Keycloak", body.OIDC.Provider) + } +} + +func TestGetAuthConfig_OIDCEnabled_DefaultProviderName(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: false}, + OIDC: config.OIDCConfig{Enabled: true, ProviderName: ""}, + }, + } + app, _ := newAuthTestApp(t, cfg) + req := httptest.NewRequest(http.MethodGet, "/auth/config", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + var body struct { + OIDC struct { + Provider string `json:"provider"` + } `json:"oidc"` + } + _ = json.NewDecoder(resp.Body).Decode(&body) + if body.OIDC.Provider != "OIDC Provider" { + t.Errorf("provider = %q, want default 'OIDC Provider'", body.OIDC.Provider) + } +} + +func TestLoginAdmin_HappyPath(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "s3cret"}, + }, + } + app, _ := newAuthTestApp(t, cfg) + + body, _ := json.Marshal(map[string]string{"username": "admin", "password": "s3cret"}) + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200\nbody: %s", resp.StatusCode, raw) + } + + var decoded struct { + Success bool `json:"success"` + Token string `json:"token"` + User struct { + Username string `json:"username"` + } `json:"user"` + } + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + t.Fatalf("decode: %v", err) + } + if !decoded.Success { + t.Error("success = false") + } + if decoded.Token == "" { + t.Error("token empty") + } + if decoded.User.Username != "admin" { + t.Errorf("username = %q, want admin", decoded.User.Username) + } +} + +func TestLoginAdmin_WrongPasswordReturns401(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "s3cret"}}, + } + app, _ := newAuthTestApp(t, cfg) + body, _ := json.Marshal(map[string]string{"username": "admin", "password": "WRONG"}) + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func TestLoginAdmin_WrongUsernameReturns401(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "s3cret"}}, + } + app, _ := newAuthTestApp(t, cfg) + body, _ := json.Marshal(map[string]string{"username": "root", "password": "s3cret"}) + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func TestLoginAdmin_MalformedJSONReturns400(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "p"}}, + } + app, _ := newAuthTestApp(t, cfg) + req := httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader("{not-json")) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestGetMe_OIDCUserInfoLocal(t *testing.T) { + cfg := &config.Config{Auth: config.AuthConfig{}} + app, h := newAuthTestApp(t, cfg) + // Re-register /auth/me with a pre-handler that seeds c.Locals("userInfo"). + // The default registration in newAuthTestApp lacks Locals; we mount a + // second path that does. + app.Get("/me-oidc", func(c fiber.Ctx) error { + c.Locals("userInfo", &auth.UserInfo{ + Username: "alice", + Email: "alice@example.com", + Name: "Alice Example", + }) + return h.GetMe(c) + }) + + req := httptest.NewRequest(http.MethodGet, "/me-oidc", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var decoded struct { + Success bool `json:"success"` + User struct { + Username string `json:"username"` + Email string `json:"email"` + Name string `json:"name"` + } `json:"user"` + } + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + t.Fatalf("decode: %v", err) + } + if decoded.User.Username != "alice" || decoded.User.Email != "alice@example.com" || decoded.User.Name != "Alice Example" { + t.Errorf("got %+v", decoded.User) + } +} + +func TestGetMe_BasicAuthUsernameLocal(t *testing.T) { + cfg := &config.Config{Auth: config.AuthConfig{}} + app, h := newAuthTestApp(t, cfg) + app.Get("/me-basic", func(c fiber.Ctx) error { + c.Locals("username", "admin") + return h.GetMe(c) + }) + req := httptest.NewRequest(http.MethodGet, "/me-basic", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var decoded struct { + User struct { + Username string `json:"username"` + } `json:"user"` + } + _ = json.NewDecoder(resp.Body).Decode(&decoded) + if decoded.User.Username != "admin" { + t.Errorf("username = %q, want admin", decoded.User.Username) + } +} + +func TestGetMe_NoLocalsReturns401(t *testing.T) { + cfg := &config.Config{Auth: config.AuthConfig{}} + app, _ := newAuthTestApp(t, cfg) + req := httptest.NewRequest(http.MethodGet, "/auth/me", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} diff --git a/backend/internal/handlers/buckets_test.go b/backend/internal/handlers/buckets_test.go new file mode 100644 index 0000000..7ff3c37 --- /dev/null +++ b/backend/internal/handlers/buckets_test.go @@ -0,0 +1,419 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services/mocks" + + "github.com/gofiber/fiber/v3" +) + +func newBucketsTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) { + t.Helper() + admin := &mocks.AdminMock{} + h := NewBucketHandler(admin, nil) // s3 unused in this handler + app := fiber.New() + app.Get("/buckets", h.ListBuckets) + app.Post("/buckets", h.CreateBucket) + app.Get("/buckets/:name", h.GetBucketInfo) + app.Delete("/buckets/:name", h.DeleteBucket) + app.Post("/buckets/:name/permissions", h.GrantBucketPermission) + app.Put("/buckets/:name/website", h.UpdateBucketWebsite) + return app, admin +} + +func decodeJSON(t *testing.T, r io.Reader, v any) { + t.Helper() + if err := json.NewDecoder(r).Decode(v); err != nil { + t.Fatalf("decode: %v", err) + } +} + +// --- ListBuckets --- + +func TestListBuckets_MapsAliasesAndStats(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) { + return []models.ListBucketsResponseItem{ + {ID: "id-1", Created: time.Unix(0, 0), GlobalAliases: []string{"alpha"}}, + {ID: "id-2", Created: time.Unix(0, 0), GlobalAliases: []string{}}, // skipped: no global alias + {ID: "id-3", Created: time.Unix(0, 0), GlobalAliases: []string{"gamma"}}, + }, nil + } + admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) { + switch alias { + case "alpha": + return &models.GarageBucketInfo{ID: "id-1", Objects: 10, Bytes: 100, WebsiteAccess: true}, nil + case "gamma": + return nil, errors.New("detail fetch failed") // degraded path + } + return nil, errors.New("unexpected alias: " + alias) + } + req := httptest.NewRequest(http.MethodGet, "/buckets", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var body struct { + Data models.BucketListResponse `json:"data"` + } + decodeJSON(t, resp.Body, &body) + if body.Data.Count != 2 { + t.Errorf("count = %d, want 2 (id-2 skipped)", body.Data.Count) + } + // alpha has stats; gamma degraded to no stats (ObjectCount/Size nil). + var foundAlpha, foundGamma bool + for _, b := range body.Data.Buckets { + if b.Name == "alpha" { + foundAlpha = true + if b.ObjectCount == nil || *b.ObjectCount != 10 { + t.Errorf("alpha.ObjectCount = %v, want 10", b.ObjectCount) + } + if !b.WebsiteAccess { + t.Error("alpha.WebsiteAccess false") + } + } + if b.Name == "gamma" { + foundGamma = true + if b.ObjectCount != nil { + t.Errorf("gamma.ObjectCount = %v, want nil (degraded)", *b.ObjectCount) + } + } + } + if !foundAlpha || !foundGamma { + t.Errorf("missing buckets: alpha=%v gamma=%v", foundAlpha, foundGamma) + } +} + +func TestListBuckets_AdminErrorReturns500(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) { + return nil, errors.New("boom") + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- CreateBucket --- + +func TestCreateBucket_Success201(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.CreateBucketFn = func(_ context.Context, r models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) { + if r.GlobalAlias == nil || *r.GlobalAlias != "new-bucket" { + t.Errorf("GlobalAlias = %v, want 'new-bucket'", r.GlobalAlias) + } + return &models.GarageBucketInfo{ID: "id-new"}, nil + } + body, _ := json.Marshal(map[string]string{"name": "new-bucket"}) + req := httptest.NewRequest(http.MethodPost, "/buckets", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, want 201", resp.StatusCode) + } +} + +func TestCreateBucket_MissingNameReturns400(t *testing.T) { + app, _ := newBucketsTestApp(t) + body, _ := json.Marshal(map[string]string{}) + req := httptest.NewRequest(http.MethodPost, "/buckets", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestCreateBucket_MalformedJSONReturns400(t *testing.T) { + app, _ := newBucketsTestApp(t) + req := httptest.NewRequest(http.MethodPost, "/buckets", strings.NewReader("{not-json")) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestCreateBucket_AdminErrorReturns500(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.CreateBucketFn = func(_ context.Context, _ models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) { + return nil, errors.New("boom") + } + body, _ := json.Marshal(map[string]string{"name": "x"}) + req := httptest.NewRequest(http.MethodPost, "/buckets", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- GetBucketInfo --- + +func TestGetBucketInfo_Success(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) { + return &models.GarageBucketInfo{ID: "id-1", Bytes: 1, Objects: 1}, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/alpha", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +func TestGetBucketInfo_NotFound404(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return nil, nil // nil pointer, nil error → 404 + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/missing", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +func TestGetBucketInfo_ServiceErrorReturns500(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return nil, errors.New("boom") + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/alpha", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- DeleteBucket --- + +func TestDeleteBucket_Success(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return &models.GarageBucketInfo{ID: "id-1"}, nil + } + admin.DeleteBucketFn = func(_ context.Context, id string) error { + if id != "id-1" { + t.Errorf("DeleteBucket id = %q, want id-1", id) + } + return nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/alpha", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +func TestDeleteBucket_NotFound(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return nil, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/missing", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +func TestDeleteBucket_AdminDeleteErrorReturns500(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return &models.GarageBucketInfo{ID: "id-1"}, nil + } + admin.DeleteBucketFn = func(_ context.Context, _ string) error { return errors.New("boom") } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/alpha", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- GrantBucketPermission --- + +func TestGrantBucketPermission_Success(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return &models.GarageBucketInfo{ID: "id-1"}, nil + } + admin.AllowBucketKeyFn = func(_ context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { + if req.BucketID != "id-1" || req.AccessKeyID != "AKIA" { + t.Errorf("req = %+v", req) + } + if !req.Permissions.Read || !req.Permissions.Write || req.Permissions.Owner { + t.Errorf("perms = %+v", req.Permissions) + } + return &models.GarageBucketInfo{ID: "id-1"}, nil + } + body, _ := json.Marshal(models.GrantBucketPermissionRequest{ + AccessKeyID: "AKIA", + Permissions: models.BucketKeyPermission{Read: true, Write: true}, + }) + req := httptest.NewRequest(http.MethodPost, "/buckets/alpha/permissions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestGrantBucketPermission_MissingAccessKey400(t *testing.T) { + app, _ := newBucketsTestApp(t) + body, _ := json.Marshal(map[string]any{"accessKeyId": "", "permissions": map[string]bool{"read": true}}) + req := httptest.NewRequest(http.MethodPost, "/buckets/alpha/permissions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestGrantBucketPermission_BucketNotFound404(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return nil, nil + } + body, _ := json.Marshal(models.GrantBucketPermissionRequest{AccessKeyID: "AKIA"}) + req := httptest.NewRequest(http.MethodPost, "/buckets/missing/permissions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +// --- UpdateBucketWebsite --- + +func TestUpdateBucketWebsite_EnableWithIndexDocument(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return &models.GarageBucketInfo{ID: "id-1"}, nil + } + admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) { + if req.WebsiteAccess == nil || !req.WebsiteAccess.Enabled { + t.Errorf("WebsiteAccess = %+v", req.WebsiteAccess) + } + if req.WebsiteAccess.IndexDocument == nil || *req.WebsiteAccess.IndexDocument != "index.html" { + t.Errorf("IndexDocument = %v", req.WebsiteAccess.IndexDocument) + } + return &models.GarageBucketInfo{ID: id, WebsiteAccess: true}, nil + } + body, _ := json.Marshal(models.UpdateBucketWebsiteRequest{Enabled: true, IndexDocument: "index.html"}) + req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/website", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestUpdateBucketWebsite_EnableWithoutIndexDocumentReturns400(t *testing.T) { + app, _ := newBucketsTestApp(t) + body, _ := json.Marshal(models.UpdateBucketWebsiteRequest{Enabled: true}) + req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/website", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestUpdateBucketWebsite_Disable(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return &models.GarageBucketInfo{ID: "id-1"}, nil + } + admin.UpdateBucketFn = func(_ context.Context, _ string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) { + if req.WebsiteAccess == nil || req.WebsiteAccess.Enabled { + t.Errorf("expected Enabled=false, got %+v", req.WebsiteAccess) + } + return &models.GarageBucketInfo{ID: "id-1"}, nil + } + body, _ := json.Marshal(models.UpdateBucketWebsiteRequest{Enabled: false}) + req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/website", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} diff --git a/backend/internal/handlers/cluster_test.go b/backend/internal/handlers/cluster_test.go new file mode 100644 index 0000000..5e04ff6 --- /dev/null +++ b/backend/internal/handlers/cluster_test.go @@ -0,0 +1,176 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services/mocks" + + "github.com/gofiber/fiber/v3" +) + +func newClusterTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) { + t.Helper() + admin := &mocks.AdminMock{} + h := NewClusterHandler(admin) + app := fiber.New() + app.Get("/cluster/health", h.GetHealth) + app.Get("/cluster/status", h.GetStatus) + app.Get("/cluster/statistics", h.GetStatistics) + app.Get("/cluster/nodes/:node_id", h.GetNodeInfo) + app.Get("/cluster/nodes/:node_id/statistics", h.GetNodeStatistics) + // Extra routes without a node_id param so we can exercise the empty-id gate. + // Fiber requires a bound param value, so we mount a path with an empty + // trailing segment explicitly via Locals. + app.Get("/cluster/nodes-empty", func(c fiber.Ctx) error { + // Set empty node_id in locals via a route that doesn't capture it. + return h.GetNodeInfo(c) + }) + return app, admin +} + +func doGet(t *testing.T, app *fiber.App, path string) *http.Response { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test %s: %v", path, err) + } + return resp +} + +func TestCluster_GetHealth_Success(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetClusterHealthFn = func(_ context.Context) (*models.ClusterHealth, error) { + return &models.ClusterHealth{Status: "healthy"}, nil + } + resp := doGet(t, app, "/cluster/health") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body struct { + Success bool `json:"success"` + Data models.ClusterHealth `json:"data"` + } + _ = json.NewDecoder(resp.Body).Decode(&body) + if !body.Success || body.Data.Status != "healthy" { + t.Errorf("body = %+v", body) + } +} + +func TestCluster_GetHealth_ServiceErrorReturns500(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetClusterHealthFn = func(_ context.Context) (*models.ClusterHealth, error) { + return nil, errors.New("upstream down") + } + resp := doGet(t, app, "/cluster/health") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +func TestCluster_GetStatus_Success(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetClusterStatusFn = func(_ context.Context) (*models.ClusterStatus, error) { + return &models.ClusterStatus{}, nil + } + resp := doGet(t, app, "/cluster/status") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestCluster_GetStatus_ServiceErrorReturns500(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetClusterStatusFn = func(_ context.Context) (*models.ClusterStatus, error) { + return nil, errors.New("boom") + } + resp := doGet(t, app, "/cluster/status") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +func TestCluster_GetStatistics_Success(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetClusterStatisticsFn = func(_ context.Context) (*models.ClusterStatistics, error) { + return &models.ClusterStatistics{}, nil + } + resp := doGet(t, app, "/cluster/statistics") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestCluster_GetStatistics_ServiceErrorReturns500(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetClusterStatisticsFn = func(_ context.Context) (*models.ClusterStatistics, error) { + return nil, errors.New("boom") + } + resp := doGet(t, app, "/cluster/statistics") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestCluster_GetNodeInfo_Success(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetNodeInfoFn = func(_ context.Context, nodeID string) (*models.MultiNodeResponse, error) { + if nodeID != "node-1" { + t.Errorf("nodeID = %q, want node-1", nodeID) + } + return &models.MultiNodeResponse{}, nil + } + resp := doGet(t, app, "/cluster/nodes/node-1") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestCluster_GetNodeInfo_ServiceErrorReturns500(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetNodeInfoFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) { + return nil, errors.New("boom") + } + resp := doGet(t, app, "/cluster/nodes/n1") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestCluster_GetNodeStatistics_Success(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetNodeStatisticsFn = func(_ context.Context, nodeID string) (*models.MultiNodeResponse, error) { + return &models.MultiNodeResponse{}, nil + } + resp := doGet(t, app, "/cluster/nodes/n1/statistics") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestCluster_GetNodeStatistics_ServiceErrorReturns500(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetNodeStatisticsFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) { + return nil, errors.New("boom") + } + resp := doGet(t, app, "/cluster/nodes/n1/statistics") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d", resp.StatusCode) + } +} diff --git a/backend/internal/handlers/health_test.go b/backend/internal/handlers/health_test.go new file mode 100644 index 0000000..425853d --- /dev/null +++ b/backend/internal/handlers/health_test.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v3" +) + +// newHealthTestApp builds a bare Fiber app with the health endpoint mounted. +func newHealthTestApp(t *testing.T, version string) *fiber.App { + t.Helper() + h := NewHealthHandler(version) + app := fiber.New() + app.Get("/health", h.Check) + return app +} + +func TestHealthCheck_ReturnsHealthyEnvelope(t *testing.T) { + app := newHealthTestApp(t, "v0.0.0-test") + req := httptest.NewRequest(http.MethodGet, "/health", nil) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + + var envelope struct { + Success bool `json:"success"` + Data struct { + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` + Version string `json:"version"` + } `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatalf("decode body: %v\n%s", err, body) + } + + if !envelope.Success { + t.Error("success = false, want true") + } + if envelope.Data.Status != "healthy" { + t.Errorf("status = %q, want healthy", envelope.Data.Status) + } + if envelope.Data.Version != "v0.0.0-test" { + t.Errorf("version = %q, want v0.0.0-test", envelope.Data.Version) + } + if envelope.Data.Timestamp.IsZero() { + t.Error("timestamp zero") + } + // Timestamp must be within a reasonable window (1 minute) of now. + if d := time.Since(envelope.Data.Timestamp); d < 0 || d > time.Minute { + t.Errorf("timestamp delta = %v, want within 1 minute of now", d) + } +} diff --git a/backend/internal/handlers/monitoring_test.go b/backend/internal/handlers/monitoring_test.go new file mode 100644 index 0000000..7aa35a2 --- /dev/null +++ b/backend/internal/handlers/monitoring_test.go @@ -0,0 +1,215 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services/mocks" + + "github.com/gofiber/fiber/v3" +) + +func newMonitoringTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) { + t.Helper() + admin := &mocks.AdminMock{} + h := NewMonitoringHandler(admin, nil) // s3Service unused by this handler + app := fiber.New() + app.Get("/monitoring/metrics", h.GetMetrics) + app.Get("/monitoring/admin-health", h.CheckAdminHealth) + app.Get("/monitoring/dashboard", h.GetDashboardMetrics) + return app, admin +} + +func TestMonitoring_GetMetrics_PassesThroughAsPlainText(t *testing.T) { + app, admin := newMonitoringTestApp(t) + admin.GetMetricsFn = func(_ context.Context) (string, error) { + return "# HELP foo\nfoo 1\n", nil + } + req := httptest.NewRequest(http.MethodGet, "/monitoring/metrics", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "text/plain; charset=utf-8" { + t.Errorf("Content-Type = %q", ct) + } + b, _ := io.ReadAll(resp.Body) + if string(b) != "# HELP foo\nfoo 1\n" { + t.Errorf("body = %q", b) + } +} + +func TestMonitoring_GetMetrics_ServiceErrorReturns500JSON(t *testing.T) { + app, admin := newMonitoringTestApp(t) + admin.GetMetricsFn = func(_ context.Context) (string, error) { + return "", errors.New("scrape failed") + } + req := httptest.NewRequest(http.MethodGet, "/monitoring/metrics", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +func TestMonitoring_CheckAdminHealth_Healthy(t *testing.T) { + app, admin := newMonitoringTestApp(t) + admin.HealthCheckFn = func(_ context.Context) error { return nil } + req := httptest.NewRequest(http.MethodGet, "/monitoring/admin-health", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body struct { + Success bool `json:"success"` + Data struct { + Status string `json:"status"` + Message string `json:"message"` + } `json:"data"` + } + _ = json.NewDecoder(resp.Body).Decode(&body) + if !body.Success || body.Data.Status != "healthy" { + t.Errorf("body = %+v", body) + } +} + +func TestMonitoring_CheckAdminHealth_Unhealthy503(t *testing.T) { + app, admin := newMonitoringTestApp(t) + admin.HealthCheckFn = func(_ context.Context) error { return errors.New("down") } + req := httptest.NewRequest(http.MethodGet, "/monitoring/admin-health", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", resp.StatusCode) + } +} + +func TestMonitoring_GetDashboardMetrics_AggregatesSizesAndPercentages(t *testing.T) { + app, admin := newMonitoringTestApp(t) + admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) { + return []models.ListBucketsResponseItem{ + {ID: "b1", Created: time.Unix(0, 0), GlobalAliases: []string{"alpha"}}, + {ID: "b2", Created: time.Unix(0, 0), GlobalAliases: []string{"beta"}}, + }, nil + } + admin.GetBucketInfoFn = func(_ context.Context, id string) (*models.GarageBucketInfo, error) { + switch id { + case "b1": + return &models.GarageBucketInfo{ID: "b1", Bytes: 300, Objects: 3}, nil + case "b2": + return &models.GarageBucketInfo{ID: "b2", Bytes: 100, Objects: 1}, nil + } + return nil, errors.New("unexpected id: " + id) + } + + req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + var body struct { + Data models.DashboardMetrics `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + + if body.Data.TotalSize != 400 { + t.Errorf("TotalSize = %d, want 400", body.Data.TotalSize) + } + if body.Data.ObjectCount != 4 { + t.Errorf("ObjectCount = %d, want 4", body.Data.ObjectCount) + } + if body.Data.BucketCount != 2 { + t.Errorf("BucketCount = %d, want 2", body.Data.BucketCount) + } + if len(body.Data.UsageByBucket) != 2 { + t.Fatalf("UsageByBucket len = %d, want 2", len(body.Data.UsageByBucket)) + } + // Percentages: 300/400 = 75, 100/400 = 25. + for _, u := range body.Data.UsageByBucket { + if u.BucketName == "alpha" && u.Percentage != 75 { + t.Errorf("alpha pct = %v, want 75", u.Percentage) + } + if u.BucketName == "beta" && u.Percentage != 25 { + t.Errorf("beta pct = %v, want 25", u.Percentage) + } + } +} + +func TestMonitoring_GetDashboardMetrics_SkipsInaccessibleBuckets(t *testing.T) { + app, admin := newMonitoringTestApp(t) + admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) { + return []models.ListBucketsResponseItem{ + {ID: "good", GlobalAliases: []string{"good"}}, + {ID: "bad", GlobalAliases: []string{"bad"}}, + }, nil + } + admin.GetBucketInfoFn = func(_ context.Context, id string) (*models.GarageBucketInfo, error) { + if id == "bad" { + return nil, errors.New("access denied") + } + return &models.GarageBucketInfo{ID: "good", Bytes: 10, Objects: 1}, nil + } + req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body struct { + Data models.DashboardMetrics `json:"data"` + } + _ = json.NewDecoder(resp.Body).Decode(&body) + if body.Data.BucketCount != 2 { + t.Errorf("BucketCount = %d, want 2 (count includes inaccessible)", body.Data.BucketCount) + } + if len(body.Data.UsageByBucket) != 1 { + t.Errorf("UsageByBucket = %d, want 1 (inaccessible skipped)", len(body.Data.UsageByBucket)) + } +} + +func TestMonitoring_GetDashboardMetrics_ListBucketsErrorReturns500(t *testing.T) { + app, admin := newMonitoringTestApp(t) + admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) { + return nil, errors.New("upstream") + } + req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} diff --git a/backend/internal/handlers/objects_helpers_test.go b/backend/internal/handlers/objects_helpers_test.go new file mode 100644 index 0000000..4811c36 --- /dev/null +++ b/backend/internal/handlers/objects_helpers_test.go @@ -0,0 +1,103 @@ +package handlers + +import ( + "strings" + "testing" +) + +func TestSafeContentType_RewritesExecutableTypes(t *testing.T) { + cases := []struct { + in, want string + }{ + {"text/html", "application/octet-stream"}, + {"text/html; charset=utf-8", "application/octet-stream"}, + {"TEXT/HTML", "application/octet-stream"}, + {" text/html ", "application/octet-stream"}, + {"application/xhtml+xml", "application/octet-stream"}, + {"image/svg+xml", "application/octet-stream"}, + {"application/xml", "application/octet-stream"}, + {"text/xml", "application/octet-stream"}, + {"application/javascript", "application/octet-stream"}, + {"text/javascript", "application/octet-stream"}, + // Safe types pass through unchanged (including parameters). + {"image/png", "image/png"}, + {"application/octet-stream", "application/octet-stream"}, + {"text/plain; charset=utf-8", "text/plain; charset=utf-8"}, + {"", ""}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + if got := safeContentType(tc.in); got != tc.want { + t.Errorf("safeContentType(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestContentDispositionHeader_StripsPathAndEscapes(t *testing.T) { + cases := []struct { + name string + disp, key string + mustContain []string + mustNotContain []string + }{ + { + name: "simple filename", + disp: "inline", key: "photo.png", + mustContain: []string{ + `inline; filename="photo.png"; filename*=UTF-8''photo.png`, + }, + }, + { + name: "path components stripped", + disp: "attachment", key: "a/b/c/file.txt", + mustContain: []string{`filename="file.txt"`, `filename*=UTF-8''file.txt`}, + mustNotContain: []string{`a/b/c`}, + }, + { + name: "quote and backslash replaced in ASCII fallback", + disp: "inline", key: `"evil\name".txt`, + mustContain: []string{`filename="_evil_name_.txt"`}, + mustNotContain: []string{`"evil`, `\name`}, + }, + { + name: "control character replaced", + disp: "inline", key: "line\nbreak.txt", + mustContain: []string{`filename="line_break.txt"`}, + }, + { + name: "non-ASCII preserved in filename* only", + disp: "inline", key: "漢字.txt", + mustContain: []string{ + `filename*=UTF-8''`, + // fallback replaced every non-ASCII rune with _ (2 runes + .txt). + `filename="__.txt"`, + }, + }, + { + name: "empty key falls back to download", + disp: "inline", key: "", + mustContain: []string{`filename="download"`, `filename*=UTF-8''download`}, + }, + { + name: "key of only slashes falls back to download", + disp: "inline", key: "/", + mustContain: []string{`filename="download"`, `filename*=UTF-8''`}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := contentDispositionHeader(tc.disp, tc.key) + for _, sub := range tc.mustContain { + if !strings.Contains(got, sub) { + t.Errorf("got %q\nmust contain %q", got, sub) + } + } + for _, sub := range tc.mustNotContain { + if strings.Contains(got, sub) { + t.Errorf("got %q\nmust NOT contain %q", got, sub) + } + } + }) + } +} diff --git a/backend/internal/handlers/objects_test.go b/backend/internal/handlers/objects_test.go new file mode 100644 index 0000000..296c4a1 --- /dev/null +++ b/backend/internal/handlers/objects_test.go @@ -0,0 +1,768 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" + "Noooste/garage-ui/internal/services/mocks" + + "github.com/gofiber/fiber/v3" +) + +func newObjectsTestApp(t *testing.T) (*fiber.App, *mocks.S3Mock) { + t.Helper() + s3 := &mocks.S3Mock{} + h := NewObjectHandler(s3) + app := fiber.New() + app.Get("/buckets/:bucket/objects", h.ListObjects) + app.Post("/buckets/:bucket/objects", h.UploadObject) + app.Post("/buckets/:bucket/objects/upload-multiple", h.UploadMultipleObjects) + app.Post("/buckets/:bucket/objects/delete-multiple", h.DeleteMultipleObjects) + // Wildcard endpoints — mount under :key for tests. Handlers prefer + // c.Locals("objectKey") but fall back to c.Params("key"), so :key works. + app.Get("/buckets/:bucket/objects/:key", h.GetObject) + app.Get("/buckets/:bucket/objects/:key/metadata", h.GetObjectMetadata) + app.Get("/buckets/:bucket/objects/:key/presigned", h.GetPresignedURL) + app.Delete("/buckets/:bucket/objects/:key", h.DeleteObject) + return app, s3 +} + +// --- ListObjects --- + +func TestListObjects_Success(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ListObjectsFn = func(_ context.Context, bucket, prefix string, max int, tok string) (*models.ObjectListResponse, error) { + if bucket != "b1" || prefix != "p/" || max != 50 || tok != "T" { + t.Errorf("args = (%q, %q, %d, %q)", bucket, prefix, max, tok) + } + return &models.ObjectListResponse{ + Bucket: bucket, Count: 1, + Objects: []models.ObjectInfo{{Key: "k1", Size: 1}}, + }, nil + } + req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?prefix=p/&max_keys=50&continuation_token=T", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var body struct { + Data models.ObjectListResponse `json:"data"` + } + decodeJSON(t, resp.Body, &body) + if body.Data.Count != 1 { + t.Errorf("count = %d", body.Data.Count) + } +} + +func TestListObjects_DefaultMaxKeys(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ListObjectsFn = func(_ context.Context, _, _ string, max int, _ string) (*models.ObjectListResponse, error) { + if max != 100 { + t.Errorf("max = %d, want default 100", max) + } + return &models.ObjectListResponse{}, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestListObjects_InvalidMaxKeys400(t *testing.T) { + app, _ := newObjectsTestApp(t) + cases := []string{"0", "-1", "abc"} + for _, mk := range cases { + t.Run(mk, func(t *testing.T) { + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?max_keys="+mk, nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } + }) + } +} + +func TestListObjects_ServiceError500(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ListObjectsFn = func(_ context.Context, _, _ string, _ int, _ string) (*models.ObjectListResponse, error) { + return nil, errors.New("boom") + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- GetObjectMetadata --- + +func TestGetObjectMetadata_Success(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.GetObjectMetadataFn = func(_ context.Context, b, k string) (*models.ObjectInfo, error) { + return &models.ObjectInfo{Key: k, Size: 42, ContentType: "image/png"}, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/metadata", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var body struct { + Data models.ObjectInfo `json:"data"` + } + decodeJSON(t, resp.Body, &body) + if body.Data.Size != 42 { + t.Errorf("size = %d", body.Data.Size) + } +} + +func TestGetObjectMetadata_NotFound404(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) { + return nil, errors.New("not found") + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/metadata", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +// --- DeleteObject --- + +func TestDeleteObject_Success(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil } + s3.DeleteObjectFn = func(_ context.Context, b, k string) error { + if b != "b1" || k != "k1" { + t.Errorf("args = (%q, %q)", b, k) + } + return nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestDeleteObject_NotExists404(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/nope", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +func TestDeleteObject_ExistsCheckError500(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, errors.New("boom") } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +func TestDeleteObject_DeleteError500(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil } + s3.DeleteObjectFn = func(_ context.Context, _, _ string) error { return errors.New("boom") } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- GetPresignedURL --- + +func TestGetPresignedURL_DefaultExpiration(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil } + s3.GetPresignedURLFn = func(_ context.Context, b, k string, exp time.Duration) (string, error) { + if exp != 3600*time.Second { + t.Errorf("exp = %v, want 1h", exp) + } + return "https://example/signed", nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var body struct { + Data models.PresignedURLResponse `json:"data"` + } + decodeJSON(t, resp.Body, &body) + if body.Data.URL != "https://example/signed" || body.Data.ExpiresIn != 3600 { + t.Errorf("body = %+v", body.Data) + } +} + +func TestGetPresignedURL_CustomExpirationWithinRange(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil } + s3.GetPresignedURLFn = func(_ context.Context, _, _ string, exp time.Duration) (string, error) { + if exp != 60*time.Second { + t.Errorf("exp = %v, want 60s", exp) + } + return "u", nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in=60", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestGetPresignedURL_InvalidExpiration400(t *testing.T) { + app, _ := newObjectsTestApp(t) + cases := []string{"0", "-1", "604801", "abc"} + for _, val := range cases { + t.Run(val, func(t *testing.T) { + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in="+val, nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } + }) + } +} + +func TestGetPresignedURL_ObjectMissing404(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/presigned", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +// --- GetObject --- + +func TestGetObject_Success_StreamsBodyAndHeaders(t *testing.T) { + app, s3 := newObjectsTestApp(t) + content := []byte("hello world") + s3.GetObjectFn = func(_ context.Context, b, k string) (io.ReadCloser, *models.ObjectInfo, error) { + return io.NopCloser(bytes.NewReader(content)), &models.ObjectInfo{ + Key: k, Size: int64(len(content)), ETag: `"etag-1"`, + ContentType: "image/png", LastModified: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got != "image/png" { + t.Errorf("Content-Type = %q", got) + } + if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("X-Content-Type-Options = %q", got) + } + if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="k1"`) { + t.Errorf("Content-Disposition = %q", resp.Header.Get("Content-Disposition")) + } + body, _ := io.ReadAll(resp.Body) + if !bytes.Equal(body, content) { + t.Errorf("body = %q, want %q", body, content) + } +} + +func TestGetObject_RewritesExecutableContentType(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) { + return io.NopCloser(strings.NewReader("")), + &models.ObjectInfo{Key: "evil.html", Size: 25, ContentType: "text/html"}, + nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/evil.html", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if got := resp.Header.Get("Content-Type"); got != "application/octet-stream" { + t.Errorf("Content-Type = %q, want application/octet-stream", got) + } +} + +func TestGetObject_DownloadQuerySetsAttachment(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) { + return io.NopCloser(strings.NewReader("x")), &models.ObjectInfo{Key: "file.txt", Size: 1}, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/file.txt?download=true", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if !strings.HasPrefix(resp.Header.Get("Content-Disposition"), "attachment") { + t.Errorf("Content-Disposition = %q, want attachment", resp.Header.Get("Content-Disposition")) + } +} + +func TestGetObject_ServiceErrorReturns404(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) { + return nil, nil, errors.New("not found") + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +// buildMultipart builds a multipart body with a single file field plus +// optional additional form fields. Returns body bytes and the Content-Type +// header value (which includes the boundary). +func buildMultipart(t *testing.T, fields map[string]string, files map[string]struct { + Filename string + Content []byte + ContentType string +}) ([]byte, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + for k, v := range fields { + if err := w.WriteField(k, v); err != nil { + t.Fatalf("WriteField: %v", err) + } + } + for name, f := range files { + h := make(map[string][]string) + h["Content-Disposition"] = []string{ + `form-data; name="` + name + `"; filename="` + f.Filename + `"`, + } + ct := f.ContentType + if ct == "" { + ct = "application/octet-stream" + } + h["Content-Type"] = []string{ct} + part, err := w.CreatePart(h) + if err != nil { + t.Fatalf("CreatePart: %v", err) + } + if _, err := part.Write(f.Content); err != nil { + t.Fatalf("part.Write: %v", err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("writer.Close: %v", err) + } + return buf.Bytes(), w.FormDataContentType() +} + +func TestUploadObject_Success(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadObjectFn = func(_ context.Context, bucket, key string, body io.Reader, ct string) (*models.ObjectUploadResponse, error) { + if bucket != "b1" || key != "myfile.bin" { + t.Errorf("args = (%q, %q)", bucket, key) + } + if ct != "application/octet-stream" { + t.Errorf("contentType = %q", ct) + } + b, _ := io.ReadAll(body) + if string(b) != "payload" { + t.Errorf("body = %q, want 'payload'", b) + } + return &models.ObjectUploadResponse{Bucket: bucket, Key: key, Size: int64(len(b))}, nil + } + body, ct := buildMultipart(t, nil, map[string]struct { + Filename string + Content []byte + ContentType string + }{ + "file": {Filename: "myfile.bin", Content: []byte("payload")}, + }) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw) + } +} + +func TestUploadObject_ExplicitKeyOverridesFilename(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadObjectFn = func(_ context.Context, _, key string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) { + if key != "custom/key.txt" { + t.Errorf("key = %q, want custom/key.txt", key) + } + return &models.ObjectUploadResponse{Key: key}, nil + } + body, ct := buildMultipart(t, map[string]string{"key": "custom/key.txt"}, map[string]struct { + Filename string + Content []byte + ContentType string + }{ + "file": {Filename: "whatever.txt", Content: []byte("x")}, + }) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestUploadObject_MissingFileReturns400(t *testing.T) { + app, _ := newObjectsTestApp(t) + body, ct := buildMultipart(t, map[string]string{"key": "k"}, nil) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestUploadObject_ServiceError500(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadObjectFn = func(_ context.Context, _, _ string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) { + return nil, errors.New("boom") + } + body, ct := buildMultipart(t, nil, map[string]struct { + Filename string + Content []byte + ContentType string + }{"file": {Filename: "f.bin", Content: []byte("x")}}) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +func TestDeleteMultipleObjects_Success(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.DeleteMultipleObjectsFn = func(_ context.Context, bucket string, keys []string) error { + if bucket != "b1" || len(keys) != 3 { + t.Errorf("args = (%q, %v)", bucket, keys) + } + return nil + } + body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b", "c"}}) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var out struct { + Data models.ObjectDeleteMultipleResponse `json:"data"` + } + decodeJSON(t, resp.Body, &out) + if out.Data.Deleted != 3 { + t.Errorf("Deleted = %d, want 3", out.Data.Deleted) + } +} + +func TestDeleteMultipleObjects_EmptyKeys400(t *testing.T) { + app, _ := newObjectsTestApp(t) + body, _ := json.Marshal(map[string]any{"keys": []string{}}) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestDeleteMultipleObjects_MalformedJSON400(t *testing.T) { + app, _ := newObjectsTestApp(t) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", strings.NewReader("{not-json")) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestDeleteMultipleObjects_ServiceError500(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, _ []string) error { return errors.New("boom") } + body, _ := json.Marshal(map[string]any{"keys": []string{"a"}}) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +// buildMultipartMulti builds a body with N "files" parts. +func buildMultipartMulti(t *testing.T, files []struct { + Filename, ContentType string + Content []byte +}) ([]byte, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + for _, f := range files { + h := map[string][]string{ + "Content-Disposition": {`form-data; name="files"; filename="` + f.Filename + `"`}, + "Content-Type": {f.ContentType}, + } + part, err := w.CreatePart(h) + if err != nil { + t.Fatalf("CreatePart: %v", err) + } + _, _ = part.Write(f.Content) + } + _ = w.Close() + return buf.Bytes(), w.FormDataContentType() +} + +func TestUploadMultiple_AllSuccess201(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadMultipleObjectsFn = func(_ context.Context, bucket string, files []struct { + Key string + Body io.Reader + ContentType string + }) []services.UploadResult { + if bucket != "b1" || len(files) != 2 { + t.Errorf("got bucket=%q files=%d", bucket, len(files)) + } + out := make([]services.UploadResult, len(files)) + for i, f := range files { + out[i] = services.UploadResult{Key: f.Key, Success: true, ContentType: f.ContentType, Size: 1} + } + return out + } + body, ct := buildMultipartMulti(t, []struct { + Filename, ContentType string + Content []byte + }{ + {"a.txt", "text/plain", []byte("a")}, + {"b.txt", "text/plain", []byte("b")}, + }) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw) + } + var out struct { + Data models.ObjectUploadMultipleResponse `json:"data"` + } + decodeJSON(t, resp.Body, &out) + if out.Data.SuccessCount != 2 || out.Data.FailureCount != 0 { + t.Errorf("counts = (%d, %d)", out.Data.SuccessCount, out.Data.FailureCount) + } +} + +func TestUploadMultiple_PartialReturns207(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct { + Key string + Body io.Reader + ContentType string + }) []services.UploadResult { + return []services.UploadResult{ + {Key: files[0].Key, Success: true, Size: 1, ContentType: files[0].ContentType}, + {Key: files[1].Key, Success: false, Error: errors.New("upload failed"), ContentType: files[1].ContentType}, + } + } + body, ct := buildMultipartMulti(t, []struct { + Filename, ContentType string + Content []byte + }{ + {"a.txt", "text/plain", []byte("a")}, + {"b.txt", "text/plain", []byte("b")}, + }) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMultiStatus { + t.Fatalf("status = %d, want 207", resp.StatusCode) + } +} + +func TestUploadMultiple_AllFailReturns500(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct { + Key string + Body io.Reader + ContentType string + }) []services.UploadResult { + out := make([]services.UploadResult, len(files)) + for i, f := range files { + out[i] = services.UploadResult{Key: f.Key, Success: false, Error: errors.New("boom")} + } + return out + } + body, ct := buildMultipartMulti(t, []struct { + Filename, ContentType string + Content []byte + }{{"a.txt", "text/plain", []byte("a")}}) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +func TestUploadMultiple_NoFiles400(t *testing.T) { + app, _ := newObjectsTestApp(t) + body, ct := buildMultipartMulti(t, nil) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestUploadMultiple_DefaultsContentType(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct { + Key string + Body io.Reader + ContentType string + }) []services.UploadResult { + if files[0].ContentType != "application/octet-stream" { + t.Errorf("ContentType = %q, want default application/octet-stream", files[0].ContentType) + } + return []services.UploadResult{{Key: files[0].Key, Success: true}} + } + // Write a part with an empty Content-Type header explicitly. + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + part, err := w.CreatePart(map[string][]string{ + "Content-Disposition": {`form-data; name="files"; filename="a.txt"`}, + "Content-Type": {""}, + }) + if err != nil { + t.Fatalf("CreatePart: %v", err) + } + _, _ = part.Write([]byte("x")) + _ = w.Close() + + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(buf.Bytes())) + req.Header.Set("Content-Type", w.FormDataContentType()) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, want 201", resp.StatusCode) + } +} diff --git a/backend/internal/handlers/users_test.go b/backend/internal/handlers/users_test.go new file mode 100644 index 0000000..30c9065 --- /dev/null +++ b/backend/internal/handlers/users_test.go @@ -0,0 +1,367 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services/mocks" + + "github.com/gofiber/fiber/v3" +) + +func newUsersTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) { + t.Helper() + admin := &mocks.AdminMock{} + h := NewUserHandler(admin) + app := fiber.New() + app.Get("/users", h.ListUsers) + app.Post("/users", h.CreateUser) + app.Get("/users/:access_key", h.GetUser) + app.Get("/users/:access_key/secret", h.GetUserSecretKey) + app.Delete("/users/:access_key", h.DeleteUser) + app.Patch("/users/:access_key", h.UpdateUserPermissions) + return app, admin +} + +// --- ListUsers --- + +func TestListUsers_MapsAndSkipsFailed(t *testing.T) { + app, admin := newUsersTestApp(t) + admin.ListKeysFn = func(_ context.Context) ([]models.ListKeysResponseItem, error) { + return []models.ListKeysResponseItem{ + {ID: "AKIA-1", Name: "one"}, + {ID: "AKIA-2", Name: "bad-detail"}, + {ID: "AKIA-3", Name: "three"}, + }, nil + } + admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) { + if showSecret { + t.Error("ListUsers must not request secret") + } + if id == "AKIA-2" { + return nil, errors.New("forbidden") + } + return &models.GarageKeyInfo{AccessKeyID: id, Name: id, Expired: false}, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var body struct { + Data models.UserListResponse `json:"data"` + } + decodeJSON(t, resp.Body, &body) + if body.Data.Count != 2 { + t.Errorf("count = %d, want 2 (failed detail skipped)", body.Data.Count) + } +} + +func TestListUsers_ListError500(t *testing.T) { + app, admin := newUsersTestApp(t) + admin.ListKeysFn = func(_ context.Context) ([]models.ListKeysResponseItem, error) { + return nil, errors.New("boom") + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- CreateUser --- + +func TestCreateUser_Success201(t *testing.T) { + app, admin := newUsersTestApp(t) + admin.CreateKeyFn = func(_ context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) { + if req.Name == nil || *req.Name != "alice" { + t.Errorf("Name = %v", req.Name) + } + sk := "secret-xyz" + return &models.GarageKeyInfo{AccessKeyID: "AKIA-1", Name: "alice", SecretAccessKey: &sk}, nil + } + body, _ := json.Marshal(models.CreateUserRequest{Name: "alice"}) + req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, want 201", resp.StatusCode) + } + var decoded struct { + Data models.UserInfo `json:"data"` + } + decodeJSON(t, resp.Body, &decoded) + if decoded.Data.SecretKey == nil || *decoded.Data.SecretKey != "secret-xyz" { + t.Errorf("SecretKey = %v, want secret-xyz", decoded.Data.SecretKey) + } +} + +func TestCreateUser_MalformedJSON400(t *testing.T) { + app, _ := newUsersTestApp(t) + req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader("{not-json")) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestCreateUser_AdminError500(t *testing.T) { + app, admin := newUsersTestApp(t) + admin.CreateKeyFn = func(_ context.Context, _ models.CreateKeyRequest) (*models.GarageKeyInfo, error) { + return nil, errors.New("boom") + } + body, _ := json.Marshal(models.CreateUserRequest{}) + req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- GetUser --- + +func TestGetUser_Success(t *testing.T) { + app, admin := newUsersTestApp(t) + admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) { + if showSecret { + t.Error("GetUser must not request secret") + } + return &models.GarageKeyInfo{AccessKeyID: id, Name: "alice"}, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestGetUser_ServiceError500(t *testing.T) { + app, admin := newUsersTestApp(t) + admin.GetKeyInfoFn = func(_ context.Context, _ string, _ bool) (*models.GarageKeyInfo, error) { + return nil, errors.New("boom") + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- GetUserSecretKey --- + +func TestGetUserSecretKey_Success(t *testing.T) { + app, admin := newUsersTestApp(t) + admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) { + if !showSecret { + t.Error("GetUserSecretKey must request secret") + } + sk := "s3cr3t" + return &models.GarageKeyInfo{AccessKeyID: id, SecretAccessKey: &sk}, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1/secret", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var body struct { + Data map[string]string `json:"data"` + } + decodeJSON(t, resp.Body, &body) + if body.Data["secretKey"] != "s3cr3t" { + t.Errorf("secretKey = %q", body.Data["secretKey"]) + } +} + +// --- DeleteUser --- + +func TestDeleteUser_Success(t *testing.T) { + app, admin := newUsersTestApp(t) + admin.DeleteKeyFn = func(_ context.Context, id string) error { + if id != "AKIA-1" { + t.Errorf("id = %q", id) + } + return nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/AKIA-1", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestDeleteUser_ServiceError500(t *testing.T) { + app, admin := newUsersTestApp(t) + admin.DeleteKeyFn = func(_ context.Context, _ string) error { return errors.New("boom") } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/AKIA-1", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- UpdateUserPermissions --- + +func TestUpdateUser_StatusInactiveSetsPastExpiration(t *testing.T) { + app, admin := newUsersTestApp(t) + admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) { + if req.NeverExpires { + t.Error("NeverExpires should be false when deactivating") + } + if req.Expiration == nil || !req.Expiration.Before(time.Now()) { + t.Errorf("Expiration = %v, want past time", req.Expiration) + } + return &models.GarageKeyInfo{}, nil + } + status := "inactive" + body, _ := json.Marshal(models.UpdateUserRequest{Status: &status}) + req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestUpdateUser_StatusActiveSetsNeverExpires(t *testing.T) { + app, admin := newUsersTestApp(t) + admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) { + if !req.NeverExpires { + t.Error("NeverExpires should be true when activating") + } + return &models.GarageKeyInfo{}, nil + } + status := "active" + body, _ := json.Marshal(models.UpdateUserRequest{Status: &status}) + req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestUpdateUser_ExplicitExpiration(t *testing.T) { + app, admin := newUsersTestApp(t) + wantTime, _ := time.Parse(time.RFC3339, "2030-01-02T03:04:05Z") + admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) { + if req.Expiration == nil || !req.Expiration.Equal(wantTime) { + t.Errorf("Expiration = %v, want %v", req.Expiration, wantTime) + } + if req.NeverExpires { + t.Error("NeverExpires should be false with explicit expiration") + } + return &models.GarageKeyInfo{}, nil + } + exp := "2030-01-02T03:04:05Z" + body, _ := json.Marshal(models.UpdateUserRequest{Expiration: &exp}) + req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestUpdateUser_BadExpirationFormat400(t *testing.T) { + app, _ := newUsersTestApp(t) + exp := "not-a-date" + body, _ := json.Marshal(models.UpdateUserRequest{Expiration: &exp}) + req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestUpdateUser_MalformedJSON400(t *testing.T) { + app, _ := newUsersTestApp(t) + req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", strings.NewReader("{not-json")) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestUpdateUser_AdminError500(t *testing.T) { + app, admin := newUsersTestApp(t) + admin.UpdateKeyFn = func(_ context.Context, _ string, _ models.UpdateKeyRequest) (*models.GarageKeyInfo, error) { + return nil, errors.New("boom") + } + status := "active" + body, _ := json.Marshal(models.UpdateUserRequest{Status: &status}) + req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} diff --git a/backend/internal/middleware/logging_test.go b/backend/internal/middleware/logging_test.go new file mode 100644 index 0000000..5ce8ba0 --- /dev/null +++ b/backend/internal/middleware/logging_test.go @@ -0,0 +1,166 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + logpkg "Noooste/garage-ui/pkg/logger" + + "github.com/gofiber/fiber/v3" + "github.com/rs/zerolog" +) + +// newLoggingTestApp installs RequestID + Logging middleware with a provided +// zerolog.Logger writing to buf so tests can assert on the JSON output. +func newLoggingTestApp(t *testing.T, buf *bytes.Buffer, handler fiber.Handler) *fiber.App { + t.Helper() + base := zerolog.New(buf) + app := fiber.New() + app.Use(RequestID()) + app.Use(Logging(base)) + app.Get("/ping", handler) + app.Get("/health", handler) + return app +} + +func parseLines(t *testing.T, buf *bytes.Buffer) []map[string]any { + t.Helper() + out := []map[string]any{} + for _, line := range strings.Split(buf.String(), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatalf("not JSON: %v — %s", err, line) + } + out = append(out, m) + } + return out +} + +func TestLogging_InjectsLoggerIntoContext(t *testing.T) { + var buf bytes.Buffer + app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error { + logpkg.FromCtx(c.Context()).Info().Str("stage", "handler").Msg("handled") + return c.SendString("ok") + }) + + req := httptest.NewRequest("GET", "/ping", nil) + if _, err := app.Test(req); err != nil { + t.Fatalf("app.Test: %v", err) + } + + lines := parseLines(t, &buf) + if len(lines) < 2 { + t.Fatalf("expected >=2 log lines (handler + access), got %d: %q", len(lines), buf.String()) + } + + h := lines[0] + if h["stage"] != "handler" { + t.Errorf("handler line stage = %v", h["stage"]) + } + if _, ok := h["request_id"].(string); !ok || h["request_id"] == "" { + t.Errorf("handler line missing request_id: %v", h) + } + if h["method"] != "GET" || h["path"] != "/ping" { + t.Errorf("handler line missing method/path: %v", h) + } +} + +func TestLogging_EmitsAccessLogLine(t *testing.T) { + var buf bytes.Buffer + app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest("GET", "/ping", nil) + if _, err := app.Test(req); err != nil { + t.Fatalf("app.Test: %v", err) + } + + lines := parseLines(t, &buf) + var access map[string]any + for i := len(lines) - 1; i >= 0; i-- { + if _, ok := lines[i]["status"]; ok { + access = lines[i] + break + } + } + if access == nil { + t.Fatalf("no access-log line found: %s", buf.String()) + } + + if access["method"] != "GET" || access["path"] != "/ping" { + t.Errorf("access line method/path wrong: %v", access) + } + if got, _ := access["status"].(float64); got != 200 { + t.Errorf("access line status = %v, want 200", access["status"]) + } + if _, ok := access["duration_ms"].(float64); !ok { + t.Errorf("access line missing duration_ms: %v", access) + } + if access["level"] != "info" { + t.Errorf("200 should log at info; got level %v", access["level"]) + } +} + +func TestLogging_SkipsHealthEndpoint(t *testing.T) { + var buf bytes.Buffer + app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest("GET", "/health", nil) + if _, err := app.Test(req); err != nil { + t.Fatalf("app.Test: %v", err) + } + + for _, line := range parseLines(t, &buf) { + if _, hasStatus := line["status"]; hasStatus { + if line["path"] == "/health" { + t.Errorf("/health should not emit an access log line: %v", line) + } + } + } +} + +func TestLogging_LevelByStatus(t *testing.T) { + cases := []struct { + status int + wantLevel string + }{ + {200, "info"}, + {404, "warn"}, + {500, "error"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.wantLevel, func(t *testing.T) { + var buf bytes.Buffer + app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error { + return c.Status(tc.status).SendString("x") + }) + req := httptest.NewRequest("GET", "/ping", nil) + if _, err := app.Test(req); err != nil { + t.Fatalf("app.Test: %v", err) + } + + var access map[string]any + for _, line := range parseLines(t, &buf) { + if _, ok := line["status"]; ok { + access = line + } + } + if access == nil { + t.Fatalf("no access line") + } + if access["level"] != tc.wantLevel { + t.Errorf("status %d → level %v, want %v", tc.status, access["level"], tc.wantLevel) + } + }) + } +} diff --git a/backend/internal/middleware/requestid.go b/backend/internal/middleware/requestid.go new file mode 100644 index 0000000..70633df --- /dev/null +++ b/backend/internal/middleware/requestid.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "github.com/gofiber/fiber/v3" + "github.com/google/uuid" +) + +// RequestIDHeader is the HTTP header used to read an incoming request ID +// (for cross-service correlation) and to echo the request ID in the response. +const RequestIDHeader = "X-Request-ID" + +// RequestIDLocalsKey is the fiber.Ctx.Locals key carrying the request ID. +const RequestIDLocalsKey = "request_id" + +// RequestID returns middleware that assigns a request ID to every request. +// If the client sends X-Request-ID, that value is used; otherwise a new +// UUIDv4 is generated. The ID is stored on c.Locals and echoed in the +// response header so clients and downstream services can correlate logs. +func RequestID() fiber.Handler { + return func(c fiber.Ctx) error { + id := c.Get(RequestIDHeader) + if id == "" { + id = uuid.NewString() + } + c.Locals(RequestIDLocalsKey, id) + c.Set(RequestIDHeader, id) + return c.Next() + } +} diff --git a/backend/internal/middleware/requestid_test.go b/backend/internal/middleware/requestid_test.go new file mode 100644 index 0000000..1f32c16 --- /dev/null +++ b/backend/internal/middleware/requestid_test.go @@ -0,0 +1,65 @@ +package middleware + +import ( + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/google/uuid" +) + +func newTestApp(t *testing.T, handler fiber.Handler, mw ...fiber.Handler) *fiber.App { + t.Helper() + app := fiber.New() + for _, m := range mw { + app.Use(m) + } + app.Get("/ping", handler) + return app +} + +func TestRequestID_GeneratesWhenAbsent(t *testing.T) { + var seen string + app := newTestApp(t, func(c fiber.Ctx) error { + seen, _ = c.Locals("request_id").(string) + return c.SendString("ok") + }, RequestID()) + + req := httptest.NewRequest("GET", "/ping", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + + if seen == "" { + t.Fatal("request_id not set on c.Locals") + } + if _, err := uuid.Parse(seen); err != nil { + t.Errorf("request_id %q is not a valid UUID: %v", seen, err) + } + if got := resp.Header.Get("X-Request-ID"); got != seen { + t.Errorf("X-Request-ID response header = %q, want %q", got, seen) + } +} + +func TestRequestID_HonorsIncomingHeader(t *testing.T) { + var seen string + app := newTestApp(t, func(c fiber.Ctx) error { + seen, _ = c.Locals("request_id").(string) + return c.SendString("ok") + }, RequestID()) + + req := httptest.NewRequest("GET", "/ping", nil) + req.Header.Set("X-Request-ID", "incoming-abc-123") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + + if seen != "incoming-abc-123" { + t.Errorf("request_id = %q, want incoming-abc-123", seen) + } + if got := resp.Header.Get("X-Request-ID"); got != "incoming-abc-123" { + t.Errorf("X-Request-ID response header = %q, want incoming-abc-123", got) + } +} diff --git a/backend/internal/services/admin_buckets_logging_test.go b/backend/internal/services/admin_buckets_logging_test.go new file mode 100644 index 0000000..45e6d64 --- /dev/null +++ b/backend/internal/services/admin_buckets_logging_test.go @@ -0,0 +1,134 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" + logpkg "Noooste/garage-ui/pkg/logger" + + "github.com/rs/zerolog" +) + +// newAdminWithServer creates a GarageAdminService pointed at a test server +// and returns it with a cleanup hook. +func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageAdminService { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + cfg := &config.GarageConfig{ + AdminEndpoint: srv.URL, + AdminToken: "test-token", + } + return NewGarageAdminService(cfg, "info") +} + +// ctxWithBufferLogger attaches a zerolog.Logger writing to buf onto ctx. +func ctxWithBufferLogger(buf *bytes.Buffer) context.Context { + l := zerolog.New(buf) + return logpkg.IntoCtx(context.Background(), l) +} + +func parseJSONLines(t *testing.T, s string) []map[string]any { + t.Helper() + out := []map[string]any{} + for _, line := range strings.Split(s, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatalf("not JSON: %v — %s", err, line) + } + out = append(out, m) + } + return out +} + +func TestCreateBucket_SuccessLogsInfoWithFields(t *testing.T) { + admin := newAdminWithServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(models.GarageBucketInfo{ID: "bkt-123"}) + }) + + var buf bytes.Buffer + ctx := ctxWithBufferLogger(&buf) + + alias := "my-bucket" + if _, err := admin.CreateBucket(ctx, models.CreateBucketAdminRequest{GlobalAlias: &alias}); err != nil { + t.Fatalf("CreateBucket: %v", err) + } + + lines := parseJSONLines(t, buf.String()) + if len(lines) < 2 { + t.Fatalf("expected >=2 lines (start + success), got %d: %s", len(lines), buf.String()) + } + + final := lines[len(lines)-1] + if final["component"] != "admin" { + t.Errorf("component = %v, want admin", final["component"]) + } + if final["operation"] != "create_bucket" { + t.Errorf("operation = %v, want create_bucket", final["operation"]) + } + if final["outcome"] != "success" { + t.Errorf("outcome = %v, want success", final["outcome"]) + } + if final["level"] != "info" { + t.Errorf("level = %v, want info", final["level"]) + } + if _, ok := final["duration_ms"].(float64); !ok { + t.Errorf("missing duration_ms: %v", final) + } + if final["bucket_id"] != "bkt-123" { + t.Errorf("bucket_id = %v, want bkt-123", final["bucket_id"]) + } +} + +func TestCreateBucket_FailureLogsError(t *testing.T) { + admin := newAdminWithServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"already exists"}`)) + }) + + var buf bytes.Buffer + ctx := ctxWithBufferLogger(&buf) + + alias := "duplicate" + _, err := admin.CreateBucket(ctx, models.CreateBucketAdminRequest{GlobalAlias: &alias}) + if err == nil { + t.Fatal("expected error, got nil") + } + + lines := parseJSONLines(t, buf.String()) + var failure map[string]any + for _, line := range lines { + if line["level"] == "error" { + failure = line + break + } + } + if failure == nil { + t.Fatalf("no error-level log line: %s", buf.String()) + } + + if failure["outcome"] != "failure" { + t.Errorf("outcome = %v, want failure", failure["outcome"]) + } + if failure["operation"] != "create_bucket" { + t.Errorf("operation = %v, want create_bucket", failure["operation"]) + } + if _, ok := failure["error"].(string); !ok { + t.Errorf("missing error field: %v", failure) + } + if _, ok := failure["duration_ms"].(float64); !ok { + t.Errorf("missing duration_ms: %v", failure) + } +} diff --git a/backend/internal/services/admin_test.go b/backend/internal/services/admin_test.go new file mode 100644 index 0000000..b21fc07 --- /dev/null +++ b/backend/internal/services/admin_test.go @@ -0,0 +1,565 @@ +package services + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" +) + +// newAdminTestServer wires an httptest.Server (with the supplied handler) to a +// fresh *GarageAdminService configured with a known bearer token. +func newAdminTestServer(t *testing.T, handler http.Handler) (*GarageAdminService, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + svc := NewGarageAdminService(&config.GarageConfig{ + AdminEndpoint: srv.URL, + AdminToken: "test-token-xyz", + }, "") + return svc, srv +} + +// jsonHandler returns an http.Handler that writes the supplied status code and +// JSON-encoded body. +func jsonHandler(t *testing.T, status int, body any) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body == nil { + return + } + if err := json.NewEncoder(w).Encode(body); err != nil { + t.Errorf("server: encode response: %v", err) + } + }) +} + +type recordedRequest struct { + method string + path string + rawURL string + auth string + body []byte +} + +func recordingHandler(t *testing.T, status int, respBody any) (http.Handler, *recordedRequest) { + t.Helper() + rec := &recordedRequest{} + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.method = r.Method + rec.path = r.URL.Path + rec.rawURL = r.URL.RequestURI() + rec.auth = r.Header.Get("Authorization") + if r.Body != nil { + b, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("server: read body: %v", err) + } + rec.body = b + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if respBody != nil { + _ = json.NewEncoder(w).Encode(respBody) + } + }) + return h, rec +} + +func TestHealthCheck_Success(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, nil) + svc, _ := newAdminTestServer(t, h) + + if err := svc.HealthCheck(context.Background()); err != nil { + t.Fatalf("HealthCheck: %v", err) + } + if rec.method != http.MethodGet { + t.Errorf("method = %q, want GET", rec.method) + } + if rec.path != "/health" { + t.Errorf("path = %q, want /health", rec.path) + } + if rec.auth != "Bearer test-token-xyz" { + t.Errorf("Authorization = %q, want Bearer test-token-xyz", rec.auth) + } +} + +func TestHealthCheck_Non2xxReturnsError(t *testing.T) { + h := jsonHandler(t, http.StatusServiceUnavailable, map[string]string{"error": "down"}) + svc, _ := newAdminTestServer(t, h) + + err := svc.HealthCheck(context.Background()) + if err == nil { + t.Fatal("expected error for 503, got nil") + } + if !strings.Contains(err.Error(), "503") { + t.Errorf("error should mention status code, got %v", err) + } +} + +func TestListKeys_Success(t *testing.T) { + want := []models.ListKeysResponseItem{ + {ID: "k1", Name: "alice", Expired: false}, + {ID: "k2", Name: "bob", Expired: true}, + } + h, rec := recordingHandler(t, http.StatusOK, want) + svc, _ := newAdminTestServer(t, h) + + got, err := svc.ListKeys(context.Background()) + if err != nil { + t.Fatalf("ListKeys: %v", err) + } + if rec.method != http.MethodGet || rec.path != "/v2/ListKeys" { + t.Errorf("request = %s %s, want GET /v2/ListKeys", rec.method, rec.path) + } + if len(got) != 2 || got[0].ID != "k1" || got[1].Name != "bob" { + t.Errorf("got %+v, want %+v", got, want) + } +} + +func TestCreateKey_PostsBodyAndDecodesResponse(t *testing.T) { + name := "service-account" + want := &models.GarageKeyInfo{ + AccessKeyID: "GK123", + Name: name, + } + h, rec := recordingHandler(t, http.StatusOK, want) + svc, _ := newAdminTestServer(t, h) + + req := models.CreateKeyRequest{Name: &name, NeverExpires: true} + got, err := svc.CreateKey(context.Background(), req) + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/v2/CreateKey" { + t.Errorf("request = %s %s", rec.method, rec.path) + } + var sent models.CreateKeyRequest + if err := json.Unmarshal(rec.body, &sent); err != nil { + t.Fatalf("unmarshal sent body: %v (raw=%q)", err, rec.body) + } + if sent.Name == nil || *sent.Name != name { + t.Errorf("sent name = %v, want %q", sent.Name, name) + } + if !sent.NeverExpires { + t.Errorf("NeverExpires should round-trip true") + } + if got.AccessKeyID != want.AccessKeyID { + t.Errorf("AccessKeyID = %q, want %q", got.AccessKeyID, want.AccessKeyID) + } +} + +func TestGetKeyInfo_PassesShowSecretQueryParam(t *testing.T) { + cases := []struct { + name string + showSecret bool + wantInRawURL string + }{ + {"without secret", false, "?id=ABC"}, + {"with secret", true, "?id=ABC&showSecretKey=true"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, &models.GarageKeyInfo{AccessKeyID: "ABC"}) + svc, _ := newAdminTestServer(t, h) + + _, err := svc.GetKeyInfo(context.Background(), "ABC", tc.showSecret) + if err != nil { + t.Fatalf("GetKeyInfo: %v", err) + } + if rec.method != http.MethodGet { + t.Errorf("method = %q, want GET", rec.method) + } + if !strings.Contains(rec.rawURL, tc.wantInRawURL) { + t.Errorf("raw URL = %q, want substring %q", rec.rawURL, tc.wantInRawURL) + } + }) + } +} + +func TestUpdateKey_UsesIDInQuery(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, &models.GarageKeyInfo{AccessKeyID: "K"}) + svc, _ := newAdminTestServer(t, h) + + if _, err := svc.UpdateKey(context.Background(), "K", models.UpdateKeyRequest{}); err != nil { + t.Fatalf("UpdateKey: %v", err) + } + if rec.method != http.MethodPost { + t.Errorf("method = %q, want POST", rec.method) + } + if !strings.Contains(rec.rawURL, "id=K") { + t.Errorf("raw URL = %q, want substring id=K", rec.rawURL) + } +} + +func TestDeleteKey_NoBodyOnSuccess(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, nil) + svc, _ := newAdminTestServer(t, h) + + if err := svc.DeleteKey(context.Background(), "K"); err != nil { + t.Fatalf("DeleteKey: %v", err) + } + if rec.method != http.MethodPost { + t.Errorf("method = %q, want POST", rec.method) + } + if !strings.Contains(rec.rawURL, "id=K") { + t.Errorf("raw URL = %q", rec.rawURL) + } +} + +func TestImportKey_PostsToImportEndpoint(t *testing.T) { + want := &models.GarageKeyInfo{AccessKeyID: "imported"} + h, rec := recordingHandler(t, http.StatusOK, want) + svc, _ := newAdminTestServer(t, h) + + req := models.ImportKeyRequest{ + AccessKeyID: "imported", + SecretAccessKey: "shhh", + } + got, err := svc.ImportKey(context.Background(), req) + if err != nil { + t.Fatalf("ImportKey: %v", err) + } + if rec.path != "/v2/ImportKey" { + t.Errorf("path = %q", rec.path) + } + if got.AccessKeyID != "imported" { + t.Errorf("AccessKeyID = %q, want imported", got.AccessKeyID) + } +} + +func TestListBuckets_Success(t *testing.T) { + want := []models.ListBucketsResponseItem{{ID: "b1"}, {ID: "b2"}} + h, rec := recordingHandler(t, http.StatusOK, want) + svc, _ := newAdminTestServer(t, h) + + got, err := svc.ListBuckets(context.Background()) + if err != nil { + t.Fatalf("ListBuckets: %v", err) + } + if rec.path != "/v2/ListBuckets" { + t.Errorf("path = %q", rec.path) + } + if len(got) != 2 { + t.Errorf("len = %d, want 2", len(got)) + } +} + +func TestGetBucketInfo_UsesIDQuery(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"}) + svc, _ := newAdminTestServer(t, h) + if _, err := svc.GetBucketInfo(context.Background(), "B"); err != nil { + t.Fatalf("GetBucketInfo: %v", err) + } + if !strings.Contains(rec.rawURL, "id=B") { + t.Errorf("raw URL = %q", rec.rawURL) + } +} + +func TestGetBucketInfoByAlias_UsesGlobalAliasQuery(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B", GlobalAliases: []string{"my-bucket"}}) + svc, _ := newAdminTestServer(t, h) + got, err := svc.GetBucketInfoByAlias(context.Background(), "my-bucket") + if err != nil { + t.Fatalf("GetBucketInfoByAlias: %v", err) + } + if !strings.Contains(rec.rawURL, "globalAlias=my-bucket") { + t.Errorf("raw URL = %q", rec.rawURL) + } + if got.ID != "B" { + t.Errorf("ID = %q, want B", got.ID) + } +} + +func TestCreateBucket_PostsBody(t *testing.T) { + want := &models.GarageBucketInfo{ID: "new"} + h, rec := recordingHandler(t, http.StatusOK, want) + svc, _ := newAdminTestServer(t, h) + + if _, err := svc.CreateBucket(context.Background(), models.CreateBucketAdminRequest{}); err != nil { + t.Fatalf("CreateBucket: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/v2/CreateBucket" { + t.Errorf("request = %s %s", rec.method, rec.path) + } +} + +func TestUpdateBucket_UsesIDQueryAndPostsBody(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"}) + svc, _ := newAdminTestServer(t, h) + if _, err := svc.UpdateBucket(context.Background(), "B", models.UpdateBucketRequest{}); err != nil { + t.Fatalf("UpdateBucket: %v", err) + } + if rec.method != http.MethodPost { + t.Errorf("method = %q", rec.method) + } + if !strings.Contains(rec.rawURL, "id=B") { + t.Errorf("raw URL = %q", rec.rawURL) + } +} + +func TestDeleteBucket_PostWithIDQuery(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, nil) + svc, _ := newAdminTestServer(t, h) + if err := svc.DeleteBucket(context.Background(), "B"); err != nil { + t.Fatalf("DeleteBucket: %v", err) + } + if rec.method != http.MethodPost { + t.Errorf("method = %q", rec.method) + } + if !strings.Contains(rec.rawURL, "id=B") { + t.Errorf("raw URL = %q", rec.rawURL) + } +} + +func TestBucketAliasAndPermissionEndpoints(t *testing.T) { + cases := []struct { + name string + fn func(s *GarageAdminService) error + path string + }{ + { + name: "AddBucketAlias", + fn: func(s *GarageAdminService) error { + _, err := s.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{}) + return err + }, + path: "/v2/AddBucketAlias", + }, + { + name: "RemoveBucketAlias", + fn: func(s *GarageAdminService) error { + _, err := s.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{}) + return err + }, + path: "/v2/RemoveBucketAlias", + }, + { + name: "AllowBucketKey", + fn: func(s *GarageAdminService) error { + _, err := s.AllowBucketKey(context.Background(), models.BucketKeyPermRequest{}) + return err + }, + path: "/v2/AllowBucketKey", + }, + { + name: "DenyBucketKey", + fn: func(s *GarageAdminService) error { + _, err := s.DenyBucketKey(context.Background(), models.BucketKeyPermRequest{}) + return err + }, + path: "/v2/DenyBucketKey", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"}) + svc, _ := newAdminTestServer(t, h) + if err := tc.fn(svc); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if rec.path != tc.path || rec.method != http.MethodPost { + t.Errorf("request = %s %s, want POST %s", rec.method, rec.path, tc.path) + } + }) + } +} + +func TestClusterEndpoints(t *testing.T) { + cases := []struct { + name string + fn func(s *GarageAdminService) error + path string + }{ + { + name: "GetClusterHealth", + fn: func(s *GarageAdminService) error { + _, err := s.GetClusterHealth(context.Background()) + return err + }, + path: "/v2/GetClusterHealth", + }, + { + name: "GetClusterStatus", + fn: func(s *GarageAdminService) error { + _, err := s.GetClusterStatus(context.Background()) + return err + }, + path: "/v2/GetClusterStatus", + }, + { + name: "GetClusterStatistics", + fn: func(s *GarageAdminService) error { + _, err := s.GetClusterStatistics(context.Background()) + return err + }, + path: "/v2/GetClusterStatistics", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, struct{}{}) + svc, _ := newAdminTestServer(t, h) + if err := tc.fn(svc); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if rec.method != http.MethodGet { + t.Errorf("method = %q, want GET", rec.method) + } + if rec.path != tc.path { + t.Errorf("path = %q, want %q", rec.path, tc.path) + } + }) + } +} + +func TestNodeEndpoints_NodeIDInQuery(t *testing.T) { + cases := []struct { + name string + fn func(s *GarageAdminService, id string) error + path string + }{ + { + name: "GetNodeInfo", + fn: func(s *GarageAdminService, id string) error { + _, err := s.GetNodeInfo(context.Background(), id) + return err + }, + path: "/v2/GetNodeInfo", + }, + { + name: "GetNodeStatistics", + fn: func(s *GarageAdminService, id string) error { + _, err := s.GetNodeStatistics(context.Background(), id) + return err + }, + path: "/v2/GetNodeStatistics", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, struct{}{}) + svc, _ := newAdminTestServer(t, h) + if err := tc.fn(svc, "node-7"); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if rec.path != tc.path { + t.Errorf("path = %q, want %q", rec.path, tc.path) + } + if !strings.Contains(rec.rawURL, "node=node-7") { + t.Errorf("raw URL = %q, want substring node=node-7", rec.rawURL) + } + }) + } +} + +func TestGetMetrics_ReturnsRawBodyOn2xx(t *testing.T) { + body := "# HELP garage_up Number of running nodes\ngarage_up 3\n" + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, body) + }) + svc, _ := newAdminTestServer(t, h) + + got, err := svc.GetMetrics(context.Background()) + if err != nil { + t.Fatalf("GetMetrics: %v", err) + } + if got != body { + t.Errorf("GetMetrics body mismatch:\n got %q\nwant %q", got, body) + } +} + +func TestGetMetrics_Non2xxReturnsErrorWithStatus(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, "forbidden") + }) + svc, _ := newAdminTestServer(t, h) + + _, err := svc.GetMetrics(context.Background()) + if err == nil { + t.Fatal("expected error for 403, got nil") + } + if !strings.Contains(err.Error(), "403") { + t.Errorf("error should mention 403, got %v", err) + } +} + +func TestDoRequest_Non2xxBodyEchoedInError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error":"bad request"}`) + }) + svc, _ := newAdminTestServer(t, h) + + _, err := svc.ListKeys(context.Background()) + if err == nil { + t.Fatal("expected error for 400, got nil") + } + if !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), "bad request") { + t.Errorf("error %v should contain 400 and the response body", err) + } +} + +func TestDoRequest_MalformedJSONReturnsDecodeError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "not-json-at-all") + }) + svc, _ := newAdminTestServer(t, h) + + _, err := svc.ListKeys(context.Background()) + if err == nil { + t.Fatal("expected decode error, got nil") + } + if !strings.Contains(err.Error(), "decode") { + t.Errorf("error %v should mention decoding", err) + } +} + +func TestDoRequest_RetriesExhaustOnConnectionRefused(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatalf("close listener: %v", err) + } + + svc := NewGarageAdminService(&config.GarageConfig{ + AdminEndpoint: "http://" + addr, + AdminToken: "irrelevant", + }, "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + start := time.Now() + _, err = svc.ListKeys(ctx) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected error after retries exhaust, got nil") + } + if !strings.Contains(err.Error(), "max retries") { + t.Errorf("expected max-retries error, got %v", err) + } + if elapsed < 200*time.Millisecond { + t.Errorf("retries returned in %v — backoff loop not engaged", elapsed) + } +} diff --git a/backend/internal/services/s3_test.go b/backend/internal/services/s3_test.go new file mode 100644 index 0000000..8623102 --- /dev/null +++ b/backend/internal/services/s3_test.go @@ -0,0 +1,331 @@ +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/pkg/utils" +) + +func TestNewS3Service_StripsHTTPPrefix(t *testing.T) { + cfg := &config.GarageConfig{ + Endpoint: "http://garage:3900", + Region: "garage", + } + _ = NewS3Service(cfg, nil) + if cfg.Endpoint != "garage:3900" { + t.Errorf("Endpoint = %q, want %q", cfg.Endpoint, "garage:3900") + } + if cfg.UseSSL { + t.Error("UseSSL should remain false for http://") + } +} + +func TestNewS3Service_StripsHTTPSPrefixAndEnablesSSL(t *testing.T) { + cfg := &config.GarageConfig{ + Endpoint: "https://garage.example.com", + Region: "garage", + } + _ = NewS3Service(cfg, nil) + if cfg.Endpoint != "garage.example.com" { + t.Errorf("Endpoint = %q", cfg.Endpoint) + } + if !cfg.UseSSL { + t.Error("UseSSL should be flipped to true for https://") + } +} + +func TestNewS3Service_LeavesBareHostUnchanged(t *testing.T) { + cfg := &config.GarageConfig{ + Endpoint: "garage:3900", + Region: "garage", + UseSSL: true, // pre-set; should remain true + } + _ = NewS3Service(cfg, nil) + if cfg.Endpoint != "garage:3900" { + t.Errorf("Endpoint mutated unexpectedly: %q", cfg.Endpoint) + } + if !cfg.UseSSL { + t.Error("pre-set UseSSL must be preserved") + } +} + +// adminBackedS3 wires an S3Service to a fresh GarageAdminService that talks +// to the supplied http.Handler. +func adminBackedS3(t *testing.T, handler http.Handler) (*S3Service, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + admin := NewGarageAdminService(&config.GarageConfig{ + AdminEndpoint: srv.URL, + AdminToken: "test-token", + }, "") + s3 := NewS3Service(&config.GarageConfig{ + Endpoint: "garage:3900", + Region: "garage", + }, admin) + return s3, srv +} + +// uniqueBucket returns a per-subtest bucket name so cached credentials from +// one test don't leak into another via utils.GlobalCache. +func uniqueBucket(t *testing.T) string { + t.Helper() + name := "test-bucket-" + t.Name() + t.Cleanup(func() { + utils.GlobalCache.Delete("key:" + name) + }) + return name +} + +func TestGetBucketCredentials_HappyPath(t *testing.T) { + bucket := uniqueBucket(t) + secret := "the-secret" + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ + ID: "bid", + Keys: []models.BucketKeyInfo{ + { + AccessKeyID: "AK", + Permissions: models.BucketKeyPermission{Read: true, Write: true}, + }, + }, + }) + }) + mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{ + AccessKeyID: "AK", + SecretAccessKey: &secret, + }) + }) + s3, _ := adminBackedS3(t, mux) + + creds, err := s3.getBucketCredentials(context.Background(), bucket) + if err != nil { + t.Fatalf("getBucketCredentials: %v", err) + } + if creds == nil { + t.Fatal("creds is nil") + } + v, err := creds.GetWithContext(nil) + if err != nil { + t.Fatalf("creds.GetWithContext: %v", err) + } + if v.AccessKeyID != "AK" || v.SecretAccessKey != secret { + t.Errorf("got AK=%q SK=%q, want AK SK=%q", v.AccessKeyID, v.SecretAccessKey, secret) + } +} + +func TestGetBucketCredentials_CachesAcrossCalls(t *testing.T) { + bucket := uniqueBucket(t) + secret := "cache-secret" + + var bucketCalls, keyCalls int + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + bucketCalls++ + _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ + ID: "bid", + Keys: []models.BucketKeyInfo{ + {AccessKeyID: "AK", Permissions: models.BucketKeyPermission{Read: true, Write: true}}, + }, + }) + }) + mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { + keyCalls++ + _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{ + AccessKeyID: "AK", + SecretAccessKey: &secret, + }) + }) + s3, _ := adminBackedS3(t, mux) + + for i := range 3 { + if _, err := s3.getBucketCredentials(context.Background(), bucket); err != nil { + t.Fatalf("call %d: %v", i, err) + } + } + if bucketCalls != 1 { + t.Errorf("GetBucketInfo called %d times, want 1 (cache should serve calls 2-3)", bucketCalls) + } + if keyCalls != 1 { + t.Errorf("GetKeyInfo called %d times, want 1", keyCalls) + } +} + +func TestGetBucketCredentials_SkipsKeysWithoutReadOrWrite(t *testing.T) { + bucket := uniqueBucket(t) + secret := "good-secret" + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ + ID: "bid", + Keys: []models.BucketKeyInfo{ + {AccessKeyID: "READ-ONLY", Permissions: models.BucketKeyPermission{Read: true, Write: false}}, + {AccessKeyID: "WRITE-ONLY", Permissions: models.BucketKeyPermission{Read: false, Write: true}}, + {AccessKeyID: "NO-PERMS", Permissions: models.BucketKeyPermission{}}, + {AccessKeyID: "RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}}, + }, + }) + }) + mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("id") != "RW" { + t.Errorf("GetKeyInfo called with id=%q, want RW", r.URL.Query().Get("id")) + } + _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{ + AccessKeyID: "RW", + SecretAccessKey: &secret, + }) + }) + s3, _ := adminBackedS3(t, mux) + + creds, err := s3.getBucketCredentials(context.Background(), bucket) + if err != nil { + t.Fatalf("getBucketCredentials: %v", err) + } + v, err := creds.GetWithContext(nil) + if err != nil { + t.Fatalf("creds.GetWithContext: %v", err) + } + if v.AccessKeyID != "RW" { + t.Errorf("AccessKeyID = %q, want RW", v.AccessKeyID) + } +} + +func TestGetBucketCredentials_NoEligibleKeyReturnsError(t *testing.T) { + bucket := uniqueBucket(t) + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ + ID: "bid", + Keys: []models.BucketKeyInfo{}, // no keys at all + }) + }) + mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { + t.Error("GetKeyInfo should not be called when no keys exist") + }) + s3, _ := adminBackedS3(t, mux) + + _, err := s3.getBucketCredentials(context.Background(), bucket) + if err == nil { + t.Fatal("expected error when bucket has no keys, got nil") + } + if !strings.Contains(err.Error(), "no valid credentials") { + t.Errorf("expected 'no valid credentials' in error, got %v", err) + } +} + +func TestGetBucketCredentials_KeyWithoutSecretIsSkipped(t *testing.T) { + bucket := uniqueBucket(t) + secret := "good" + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ + ID: "bid", + Keys: []models.BucketKeyInfo{ + {AccessKeyID: "FIRST-RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}}, + {AccessKeyID: "SECOND-RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}}, + }, + }) + }) + mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("id") { + case "FIRST-RW": + _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{AccessKeyID: "FIRST-RW", SecretAccessKey: nil}) + case "SECOND-RW": + _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{AccessKeyID: "SECOND-RW", SecretAccessKey: &secret}) + default: + t.Errorf("unexpected GetKeyInfo id: %q", r.URL.Query().Get("id")) + } + }) + s3, _ := adminBackedS3(t, mux) + + creds, err := s3.getBucketCredentials(context.Background(), bucket) + if err != nil { + t.Fatalf("getBucketCredentials: %v", err) + } + v, err := creds.GetWithContext(nil) + if err != nil { + t.Fatalf("creds.GetWithContext: %v", err) + } + if v.AccessKeyID != "SECOND-RW" { + t.Errorf("AccessKeyID = %q, want SECOND-RW (loop must skip FIRST-RW's nil secret)", v.AccessKeyID) + } +} + +func TestGetBucketCredentials_AdminErrorPropagates(t *testing.T) { + bucket := uniqueBucket(t) + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"boom"}`)) + }) + s3, _ := adminBackedS3(t, mux) + + _, err := s3.getBucketCredentials(context.Background(), bucket) + if err == nil { + t.Fatal("expected error when admin call fails, got nil") + } + if !strings.Contains(err.Error(), "failed to get bucket info") { + t.Errorf("expected wrapped 'failed to get bucket info' error, got %v", err) + } +} + +func TestGetBucketStatistics_HappyPath(t *testing.T) { + bucket := uniqueBucket(t) + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.RawQuery, "globalAlias=") { + t.Errorf("expected globalAlias query, got %q", r.URL.RawQuery) + } + _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ + ID: "bid", + Objects: 42, + Bytes: 123_456, + }) + }) + s3, _ := adminBackedS3(t, mux) + + stats, err := s3.GetBucketStatistics(context.Background(), bucket) + if err != nil { + t.Fatalf("GetBucketStatistics: %v", err) + } + if stats.ObjectCount != 42 || stats.TotalSize != 123_456 { + t.Errorf("got %+v, want ObjectCount=42 TotalSize=123456", stats) + } +} + +func TestGetBucketStatistics_AdminErrorPropagates(t *testing.T) { + bucket := uniqueBucket(t) + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"no such bucket"}`)) + }) + s3, _ := adminBackedS3(t, mux) + + _, err := s3.GetBucketStatistics(context.Background(), bucket) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to get bucket info") { + t.Errorf("expected 'failed to get bucket info' wrap, got %v", err) + } +} diff --git a/backend/pkg/logger/redact_test.go b/backend/pkg/logger/redact_test.go new file mode 100644 index 0000000..d78467f --- /dev/null +++ b/backend/pkg/logger/redact_test.go @@ -0,0 +1,32 @@ +package logger + +import "testing" + +func TestRedactKey(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"empty", "", "***"}, + {"short", "abc", "***"}, + {"just-under-threshold", "abcdefghijk", "***"}, // 11 chars + {"at-threshold", "abcdefghijkl", "abcd…ijkl"}, // 12 chars + {"long", "GK5a9bfcdefghijklzW9q", "GK5a…zW9q"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := RedactKey(tc.in); got != tc.want { + t.Errorf("RedactKey(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestRedactToken_AlwaysStars(t *testing.T) { + for _, in := range []string{"", "abc", "very-long-secret-token-value"} { + if got := RedactToken(in); got != "***" { + t.Errorf("RedactToken(%q) = %q, want ***", in, got) + } + } +}