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