diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d7828f6..6b546cc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,6 +45,14 @@ jobs: name: coverage path: coverage.out + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: Noooste/garage-ui + files: ./coverage.out + fail_ci_if_error: false + smoke: name: Smoke test (docker compose) runs-on: ubuntu-latest @@ -72,3 +80,4 @@ jobs: run: | docker compose -p garage-ui-smoke \ -f backend/tests/smoke/docker-compose.test.yml down -v || true + diff --git a/backend/internal/auth/auth_more_test.go b/backend/internal/auth/auth_more_test.go new file mode 100644 index 0000000..2a0a399 --- /dev/null +++ b/backend/internal/auth/auth_more_test.go @@ -0,0 +1,267 @@ +package auth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "Noooste/garage-ui/internal/config" + + "golang.org/x/oauth2" +) + +// newOIDCServerWithTokenAndUserInfo extends the minimal discovery stub with +// token and userinfo endpoints so ExchangeCode and GetUserInfo can be driven +// end-to-end without a real IdP. +func newOIDCServerWithTokenAndUserInfo( + t *testing.T, + tokenResp map[string]any, + tokenStatus int, + userInfoResp map[string]any, + userInfoStatus int, +) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/auth", + "token_endpoint": srv.URL + "/token", + "jwks_uri": srv.URL + "/jwks", + "userinfo_endpoint": srv.URL + "/userinfo", + "id_token_signing_alg_values_supported": []string{"RS256"}, + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"keys":[]}`)) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tokenStatus) + if tokenResp != nil { + _ = json.NewEncoder(w).Encode(tokenResp) + } + }) + mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(userInfoStatus) + if userInfoResp != nil { + _ = json.NewEncoder(w).Encode(userInfoResp) + } + }) + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestExchangeCode_OIDCDisabledReturnsError(t *testing.T) { + svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}} + if _, err := svc.ExchangeCode(context.Background(), "some-code"); err == nil { + t.Fatal("expected error when OIDC not initialized") + } +} + +func TestExchangeCode_TokenEndpointErrorPropagates(t *testing.T) { + srv := newOIDCServerWithTokenAndUserInfo(t, + map[string]any{"error": "invalid_grant"}, http.StatusBadRequest, + nil, http.StatusOK, + ) + svc, err := NewAuthService( + &config.AuthConfig{OIDC: config.OIDCConfig{ + Enabled: true, + ClientID: "c", + IssuerURL: srv.URL, + Scopes: []string{"openid"}, + }}, + &config.ServerConfig{RootURL: "https://example.test"}, + ) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + + _, err = svc.ExchangeCode(context.Background(), "bad-code") + if err == nil { + t.Fatal("expected error for 400 from token endpoint") + } + if !strings.Contains(err.Error(), "failed to exchange code") { + t.Errorf("error = %v, want wrap 'failed to exchange code'", err) + } +} + +func TestVerifyIDToken_OIDCDisabledReturnsError(t *testing.T) { + svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}} + if _, err := svc.VerifyIDToken(context.Background(), "tok"); err == nil { + t.Fatal("expected error when OIDC not initialized") + } +} + +func TestVerifyIDToken_GarbageTokenRejected(t *testing.T) { + // Discovery works; JWKS is empty so no signature can verify — any token + // is rejected. This exercises the verifier error path. + srv := newOIDCServerWithTokenAndUserInfo(t, nil, http.StatusOK, nil, http.StatusOK) + svc, err := NewAuthService( + &config.AuthConfig{OIDC: config.OIDCConfig{ + Enabled: true, + ClientID: "c", + IssuerURL: srv.URL, + Scopes: []string{"openid"}, + }}, + &config.ServerConfig{RootURL: "https://example.test"}, + ) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + + _, err = svc.VerifyIDToken(context.Background(), "not-a-real-jwt") + if err == nil { + t.Fatal("expected verifier error for garbage token") + } +} + +func TestGetUserInfo_OIDCDisabledReturnsError(t *testing.T) { + svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}} + _, err := svc.GetUserInfo(context.Background(), &oauth2.Token{AccessToken: "x"}) + if err == nil { + t.Fatal("expected error when OIDC not initialized") + } +} + +func TestGetUserInfo_ProviderErrorPropagates(t *testing.T) { + srv := newOIDCServerWithTokenAndUserInfo(t, + nil, http.StatusOK, + map[string]any{"error": "invalid_token"}, http.StatusUnauthorized, + ) + svc, err := NewAuthService( + &config.AuthConfig{OIDC: config.OIDCConfig{ + Enabled: true, + ClientID: "c", + IssuerURL: srv.URL, + Scopes: []string{"openid"}, + }}, + &config.ServerConfig{RootURL: "https://example.test"}, + ) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + + _, err = svc.GetUserInfo(context.Background(), &oauth2.Token{AccessToken: "bad"}) + if err == nil { + t.Fatal("expected error from userinfo endpoint") + } + if !strings.Contains(err.Error(), "failed to get user info") { + t.Errorf("error = %v", err) + } +} + +func TestGetUserInfo_HappyPath_ExtractsClaims(t *testing.T) { + srv := newOIDCServerWithTokenAndUserInfo(t, + nil, http.StatusOK, + map[string]any{ + "sub": "user-123", + "preferred_username": "alice", + "email": "alice@example.com", + "name": "Alice Example", + "resource_access": map[string]any{ + "garage": map[string]any{ + "roles": []any{"admin", "user"}, + }, + }, + }, + http.StatusOK, + ) + svc, err := NewAuthService( + &config.AuthConfig{OIDC: config.OIDCConfig{ + Enabled: true, + ClientID: "c", + IssuerURL: srv.URL, + Scopes: []string{"openid"}, + UsernameAttribute: "preferred_username", + EmailAttribute: "email", + NameAttribute: "name", + RoleAttributePath: "resource_access.garage.roles", + }}, + &config.ServerConfig{RootURL: "https://example.test"}, + ) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + + info, err := svc.GetUserInfo(context.Background(), &oauth2.Token{AccessToken: "good"}) + if err != nil { + t.Fatalf("GetUserInfo: %v", err) + } + if info.Username != "alice" || info.Email != "alice@example.com" || info.Name != "Alice Example" { + t.Errorf("got %+v, want alice/alice@example.com/Alice Example", info) + } + if len(info.Roles) != 2 || info.Roles[0] != "admin" { + t.Errorf("Roles = %v, want [admin user]", info.Roles) + } +} + +func TestExtractClaim(t *testing.T) { + cases := []struct { + name string + claims map[string]interface{} + key string + want string + }{ + {"empty key returns empty", map[string]interface{}{"a": "b"}, "", ""}, + {"missing key returns empty", map[string]interface{}{"a": "b"}, "missing", ""}, + {"non-string value returns empty", map[string]interface{}{"n": 42}, "n", ""}, + {"string value returned", map[string]interface{}{"email": "x@y"}, "email", "x@y"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := extractClaim(tc.claims, tc.key); got != tc.want { + t.Errorf("extractClaim(%v, %q) = %q, want %q", tc.claims, tc.key, got, tc.want) + } + }) + } +} + +func TestGenerateAndValidateStateToken_Roundtrip(t *testing.T) { + jwtSvc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + svc := &Service{ + authConfig: &config.AuthConfig{}, + serverConfig: &config.ServerConfig{}, + jwtService: jwtSvc, + } + + tok, err := svc.GenerateStateToken() + if err != nil { + t.Fatalf("GenerateStateToken: %v", err) + } + if tok == "" { + t.Fatal("state token is empty") + } + if !svc.ValidateAndConsumeState(tok) { + t.Fatal("ValidateAndConsumeState rejected a freshly-issued token") + } + // Double-consume must fail (CSRF single-use). + if svc.ValidateAndConsumeState(tok) { + t.Fatal("ValidateAndConsumeState accepted a re-used token") + } +} + +func TestValidateAndConsumeState_RejectsGarbage(t *testing.T) { + jwtSvc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + svc := &Service{jwtService: jwtSvc} + if svc.ValidateAndConsumeState("definitely-not-a-token") { + t.Fatal("accepted an invalid token") + } +} diff --git a/backend/internal/handlers/objects_test.go b/backend/internal/handlers/objects_test.go index 296c4a1..8cbb3f1 100644 --- a/backend/internal/handlers/objects_test.go +++ b/backend/internal/handlers/objects_test.go @@ -27,6 +27,7 @@ func newObjectsTestApp(t *testing.T) (*fiber.App, *mocks.S3Mock) { app := fiber.New() app.Get("/buckets/:bucket/objects", h.ListObjects) app.Post("/buckets/:bucket/objects", h.UploadObject) + app.Post("/buckets/:bucket/directories", h.CreateDirectory) 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 @@ -766,3 +767,100 @@ func TestUploadMultiple_DefaultsContentType(t *testing.T) { t.Fatalf("status = %d, want 201", resp.StatusCode) } } + +// --- CreateDirectory --- + +func TestCreateDirectory_Success_AppendsTrailingSlash(t *testing.T) { + app, s3 := newObjectsTestApp(t) + var gotKey string + s3.CreateDirectoryMarkerFn = func(_ context.Context, bucket, key string) (*models.ObjectUploadResponse, error) { + gotKey = key + return &models.ObjectUploadResponse{Bucket: bucket, Key: key, Size: 0, ContentType: "application/x-directory"}, nil + } + + body := bytes.NewBufferString(`{"key": "photos/2024"}`) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", 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) + } + if gotKey != "photos/2024/" { + t.Errorf("service key = %q, want trailing slash appended", gotKey) + } +} + +func TestCreateDirectory_StripsLeadingSlashes(t *testing.T) { + app, s3 := newObjectsTestApp(t) + var gotKey string + s3.CreateDirectoryMarkerFn = func(_ context.Context, _, key string) (*models.ObjectUploadResponse, error) { + gotKey = key + return &models.ObjectUploadResponse{Key: key}, nil + } + + body := bytes.NewBufferString(`{"key": "///already/"}`) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", 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) + } + if gotKey != "already/" { + t.Errorf("service key = %q, want 'already/'", gotKey) + } +} + +func TestCreateDirectory_MissingKey400(t *testing.T) { + app, _ := newObjectsTestApp(t) + body := bytes.NewBufferString(`{"key": ""}`) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", 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 TestCreateDirectory_MalformedJSON400(t *testing.T) { + app, _ := newObjectsTestApp(t) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", bytes.NewBufferString("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 TestCreateDirectory_ServiceError500(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.CreateDirectoryMarkerFn = func(_ context.Context, _, _ string) (*models.ObjectUploadResponse, error) { + return nil, errors.New("boom") + } + body := bytes.NewBufferString(`{"key": "x/"}`) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", 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/models/responses_test.go b/backend/internal/models/responses_test.go new file mode 100644 index 0000000..11b3857 --- /dev/null +++ b/backend/internal/models/responses_test.go @@ -0,0 +1,47 @@ +package models + +import "testing" + +func TestSuccessResponse(t *testing.T) { + payload := map[string]int{"count": 3} + r := SuccessResponse(payload) + if !r.Success { + t.Error("Success should be true") + } + if r.Error != nil { + t.Errorf("Error should be nil, got %+v", r.Error) + } + m, ok := r.Data.(map[string]int) + if !ok { + t.Fatalf("Data type = %T, want map[string]int", r.Data) + } + if m["count"] != 3 { + t.Errorf("Data.count = %d, want 3", m["count"]) + } +} + +func TestSuccessResponse_NilData(t *testing.T) { + r := SuccessResponse(nil) + if !r.Success { + t.Error("Success should be true even with nil data") + } + if r.Data != nil { + t.Errorf("Data = %v, want nil", r.Data) + } +} + +func TestErrorResponse(t *testing.T) { + r := ErrorResponse(ErrCodeBadRequest, "bad input") + if r.Success { + t.Error("Success should be false for error response") + } + if r.Data != nil { + t.Errorf("Data should be nil, got %v", r.Data) + } + if r.Error == nil { + t.Fatal("Error should not be nil") + } + if r.Error.Code != ErrCodeBadRequest || r.Error.Message != "bad input" { + t.Errorf("Error = %+v", r.Error) + } +} diff --git a/backend/internal/routes/routes_more_test.go b/backend/internal/routes/routes_more_test.go new file mode 100644 index 0000000..7d28f31 --- /dev/null +++ b/backend/internal/routes/routes_more_test.go @@ -0,0 +1,254 @@ +package routes + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" +) + +// newNoAuthFixture builds a fixture with both admin and OIDC disabled. The +// AuthMiddleware short-circuits with c.Next(), letting handler logic run so +// wildcard-route dispatch can be exercised. +func newNoAuthFixture(t *testing.T) *routeFixture { + return newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = false + c.Auth.OIDC.Enabled = false + }) +} + +func plainReq(method, path string, body io.Reader) *http.Request { + return httptest.NewRequest(method, path, body) +} + +func TestRoutes_ObjectWildcard_GET_DefaultRoutesToGetObject(t *testing.T) { + f := newNoAuthFixture(t) + + var gotBucket, gotKey string + f.S3.GetObjectFn = func(_ context.Context, bucket, key string) (io.ReadCloser, *models.ObjectInfo, error) { + gotBucket, gotKey = bucket, key + return io.NopCloser(strings.NewReader("hello")), &models.ObjectInfo{Key: key, Size: 5, ContentType: "text/plain"}, nil + } + + req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/folder/subdir/file.txt", nil) + resp, err := f.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 gotBucket != "b1" || gotKey != "folder/subdir/file.txt" { + t.Errorf("service called with (%q, %q)", gotBucket, gotKey) + } +} + +func TestRoutes_ObjectWildcard_GET_MetadataSuffixRoutesToMetadata(t *testing.T) { + f := newNoAuthFixture(t) + + var gotKey string + f.S3.GetObjectMetadataFn = func(_ context.Context, _ string, key string) (*models.ObjectInfo, error) { + gotKey = key + return &models.ObjectInfo{Key: key, Size: 42, ContentType: "application/json"}, nil + } + + req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/data/file.bin/metadata", nil) + resp, err := f.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) + } + // The handler strips the /metadata suffix before calling the service. + if gotKey != "data/file.bin" { + t.Errorf("key passed to service = %q, want 'data/file.bin'", gotKey) + } +} + +func TestRoutes_ObjectWildcard_GET_PresignSuffixRoutesToPresigned(t *testing.T) { + f := newNoAuthFixture(t) + + // The object must exist for the presign handler to succeed. + f.S3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil } + + var gotKey string + f.S3.GetPresignedURLFn = func(_ context.Context, _ string, key string, _ time.Duration) (string, error) { + gotKey = key + return "https://signed.example/k", nil + } + + req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/sub/file.bin/presign", nil) + resp, err := f.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 — body: (unavailable)", resp.StatusCode) + } + if gotKey != "sub/file.bin" { + t.Errorf("key passed to service = %q, want 'sub/file.bin'", gotKey) + } +} + +func TestRoutes_ObjectWildcard_DELETE_RoutesToDeleteObject(t *testing.T) { + f := newNoAuthFixture(t) + + f.S3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil } + + var gotKey string + f.S3.DeleteObjectFn = func(_ context.Context, _ string, key string) error { + gotKey = key + return nil + } + + req := plainReq(http.MethodDelete, "/api/v1/buckets/b1/objects/path/to/delete.txt", nil) + resp, err := f.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 gotKey != "path/to/delete.txt" { + t.Errorf("key passed to service = %q", gotKey) + } +} + +func TestRoutes_ObjectWildcard_HEAD_RoutesToMetadata(t *testing.T) { + f := newNoAuthFixture(t) + + var gotKey string + f.S3.GetObjectMetadataFn = func(_ context.Context, _ string, key string) (*models.ObjectInfo, error) { + gotKey = key + return &models.ObjectInfo{Key: key, Size: 7, ContentType: "text/plain"}, nil + } + + req := plainReq(http.MethodHead, "/api/v1/buckets/b1/objects/deep/nested/x.txt", nil) + resp, err := f.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 gotKey != "deep/nested/x.txt" { + t.Errorf("key passed to service = %q", gotKey) + } +} + +func TestRoutes_ObjectWildcard_URLDecodedBeforeDispatch(t *testing.T) { + // %20 in the wildcard portion must be decoded before the service is called. + f := newNoAuthFixture(t) + + var gotKey string + f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) { + gotKey = key + return io.NopCloser(strings.NewReader("")), &models.ObjectInfo{Key: key}, nil + } + + req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/with%20space/file.txt", nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if gotKey != "with space/file.txt" { + t.Errorf("decoded key = %q, want 'with space/file.txt'", gotKey) + } +} + +// Covers the "skip SPA fallback for API-prefixed paths" branch without +// triggering SendFile (which holds file handles on Windows and races with +// t.TempDir cleanup). Only API/auth/health/docs paths are hit here — the +// fallback short-circuits to c.Next(), so no file is opened. +func TestRoutes_SPAFallback_SkipsAPIAndAuthPrefixes(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + // The presence of ./frontend/dist is what enables the fallback middleware. + if err := os.MkdirAll(filepath.Join(dir, "frontend", "dist"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // We deliberately do NOT create index.html — the test must not reach SendFile. + + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "u" + c.Auth.Admin.Password = "p" + }) + + // Every prefix listed in the fallback's skip-list should bypass file + // serving and return 404 from fiber's default handler. + for _, p := range []string{ + "/api/v1/definitely-not-a-route", + "/auth/nope", + "/health/extra/segments", + "/docs/missing", + } { + req := httptest.NewRequest(http.MethodGet, p, nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("%s: %v", p, err) + } + _ = resp.Body.Close() + // /api/v1/* hits auth middleware → 401. The others hit the SPA + // fallback's skip branch then fall through to 404. We only care that + // the SPA middleware did NOT attempt to serve index.html (which would + // succeed with 200 if it existed — here it doesn't exist, so SendFile + // would error; either way, != 200 suffices to prove the skip path ran). + if resp.StatusCode == 200 { + t.Errorf("%s returned 200 — SPA fallback should have skipped", p) + } + } +} + +// Covers the third OIDC role-resolution fallback: when neither the ID token +// nor the access token exposes roles, the callback calls GetUserInfo and +// re-evaluates IsAdmin against those roles. +func TestRoutes_OIDCCallback_RoleMatchedViaUserInfoFallback_Succeeds(t *testing.T) { + f, iss := newOIDCFixture(t, "admin") + + // ID token and access token have no roles by default — leave as-is. + // Override /userinfo to return a roles structure matching the configured + // RoleAttributePath (resource_access.test-client.roles). + iss.UserInfoFn = func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sub": "user-1", + "preferred_username": "alice", + "email": "alice@example.com", + "resource_access": map[string]any{ + "test-client": map[string]any{"roles": []any{"admin"}}, + }, + }) + } + + state := oidcState(t, f) + req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state="+state+"&code=c", nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 303 { + t.Fatalf("status = %d, want 303 (userinfo fallback should grant admin)", resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != "/login?login=success" { + t.Errorf("Location = %q", loc) + } +} diff --git a/backend/internal/services/mocks/mocks_test.go b/backend/internal/services/mocks/mocks_test.go new file mode 100644 index 0000000..5f90b16 --- /dev/null +++ b/backend/internal/services/mocks/mocks_test.go @@ -0,0 +1,214 @@ +package mocks + +import ( + "context" + "io" + "strings" + "testing" + "time" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" +) + +// The mocks are exercised across the handlers test suite, but Go's per- +// package coverage counts only statements executed from tests in THIS +// package. This test calls every mock method with no Fn configured, which +// covers the default "not configured" error path (and the record() helpers +// that build the call log). + +func TestAdminMock_UnconfiguredMethodsReturnSentinel(t *testing.T) { + ctx := context.Background() + m := &AdminMock{} + + type call struct { + name string + fn func() error + } + calls := []call{ + {"ListKeys", func() error { _, e := m.ListKeys(ctx); return e }}, + {"CreateKey", func() error { _, e := m.CreateKey(ctx, models.CreateKeyRequest{}); return e }}, + {"GetKeyInfo", func() error { _, e := m.GetKeyInfo(ctx, "k", false); return e }}, + {"UpdateKey", func() error { _, e := m.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); return e }}, + {"DeleteKey", func() error { return m.DeleteKey(ctx, "k") }}, + {"ListBuckets", func() error { _, e := m.ListBuckets(ctx); return e }}, + {"GetBucketInfo", func() error { _, e := m.GetBucketInfo(ctx, "b"); return e }}, + {"GetBucketInfoByAlias", func() error { _, e := m.GetBucketInfoByAlias(ctx, "a"); return e }}, + {"CreateBucket", func() error { _, e := m.CreateBucket(ctx, models.CreateBucketAdminRequest{}); return e }}, + {"UpdateBucket", func() error { _, e := m.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); return e }}, + {"DeleteBucket", func() error { return m.DeleteBucket(ctx, "b") }}, + {"AllowBucketKey", func() error { _, e := m.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); return e }}, + {"GetClusterHealth", func() error { _, e := m.GetClusterHealth(ctx); return e }}, + {"GetClusterStatus", func() error { _, e := m.GetClusterStatus(ctx); return e }}, + {"GetClusterStatistics", func() error { _, e := m.GetClusterStatistics(ctx); return e }}, + {"GetNodeInfo", func() error { _, e := m.GetNodeInfo(ctx, "n"); return e }}, + {"GetNodeStatistics", func() error { _, e := m.GetNodeStatistics(ctx, "n"); return e }}, + {"HealthCheck", func() error { return m.HealthCheck(ctx) }}, + {"GetMetrics", func() error { _, e := m.GetMetrics(ctx); return e }}, + } + + for _, c := range calls { + err := c.fn() + if err == nil { + t.Errorf("%s: expected error from unconfigured mock, got nil", c.name) + continue + } + if !strings.Contains(err.Error(), c.name) { + t.Errorf("%s: error %q should mention method name", c.name, err.Error()) + } + } + if len(m.Calls) != len(calls) { + t.Errorf("Calls recorded = %d, want %d", len(m.Calls), len(calls)) + } +} + +func TestAdminMock_ConfiguredFnsAreInvoked(t *testing.T) { + ctx := context.Background() + m := &AdminMock{ + ListKeysFn: func(ctx context.Context) ([]models.ListKeysResponseItem, error) { + return []models.ListKeysResponseItem{{ID: "k1"}}, nil + }, + DeleteKeyFn: func(ctx context.Context, id string) error { return nil }, + HealthCheckFn: func(ctx context.Context) error { return nil }, + GetMetricsFn: func(ctx context.Context) (string, error) { return "metric 1", nil }, + } + if got, err := m.ListKeys(ctx); err != nil || len(got) != 1 { + t.Errorf("ListKeys = (%v, %v), want one item", got, err) + } + if err := m.DeleteKey(ctx, "k"); err != nil { + t.Errorf("DeleteKey: %v", err) + } + if err := m.HealthCheck(ctx); err != nil { + t.Errorf("HealthCheck: %v", err) + } + if got, _ := m.GetMetrics(ctx); got != "metric 1" { + t.Errorf("GetMetrics = %q", got) + } +} + +func TestS3Mock_UnconfiguredMethodsReturnSentinel(t *testing.T) { + ctx := context.Background() + m := &S3Mock{} + + if _, err := m.ListObjects(ctx, "b", "", 0, ""); err == nil { + t.Error("ListObjects: want error") + } + if _, err := m.UploadObject(ctx, "b", "k", strings.NewReader(""), ""); err == nil { + t.Error("UploadObject: want error") + } + if _, err := m.CreateDirectoryMarker(ctx, "b", "k/"); err == nil { + t.Error("CreateDirectoryMarker: want error") + } + if _, _, err := m.GetObject(ctx, "b", "k"); err == nil { + t.Error("GetObject: want error") + } + if _, err := m.ObjectExists(ctx, "b", "k"); err == nil { + t.Error("ObjectExists: want error") + } + if err := m.DeleteObject(ctx, "b", "k"); err == nil { + t.Error("DeleteObject: want error") + } + if _, err := m.GetObjectMetadata(ctx, "b", "k"); err == nil { + t.Error("GetObjectMetadata: want error") + } + if _, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err == nil { + t.Error("GetPresignedURL: want error") + } + if err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err == nil { + t.Error("DeleteMultipleObjects: want error") + } + // UploadMultipleObjects has no error channel; it must return a result slice + // with one failed entry per input file. + results := m.UploadMultipleObjects(ctx, "b", []struct { + Key string + Body io.Reader + ContentType string + }{ + {Key: "a"}, {Key: "b"}, + }) + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2", len(results)) + } + for _, r := range results { + if r.Success { + t.Errorf("result[%s] should not be successful with no Fn set", r.Key) + } + } + if len(m.Calls) < 10 { + t.Errorf("expected Calls to capture each invocation, got %d entries", len(m.Calls)) + } +} + +func TestS3Mock_ConfiguredFnsAreInvoked(t *testing.T) { + ctx := context.Background() + m := &S3Mock{ + ListObjectsFn: func(_ context.Context, _, _ string, _ int, _ string) (*models.ObjectListResponse, error) { + return &models.ObjectListResponse{Count: 1}, nil + }, + UploadObjectFn: func(_ context.Context, _, _ string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) { + return &models.ObjectUploadResponse{}, nil + }, + CreateDirectoryMarkerFn: func(_ context.Context, _, _ string) (*models.ObjectUploadResponse, error) { + return &models.ObjectUploadResponse{}, nil + }, + GetObjectFn: func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) { + return io.NopCloser(strings.NewReader("x")), &models.ObjectInfo{}, nil + }, + ObjectExistsFn: func(_ context.Context, _, _ string) (bool, error) { return true, nil }, + DeleteObjectFn: func(_ context.Context, _, _ string) error { return nil }, + GetObjectMetadataFn: func(_ context.Context, _, _ string) (*models.ObjectInfo, error) { + return &models.ObjectInfo{}, nil + }, + GetPresignedURLFn: func(_ context.Context, _, _ string, _ time.Duration) (string, error) { + return "http://x", nil + }, + DeleteMultipleObjectsFn: func(_ context.Context, _ string, _ []string) error { return nil }, + 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: true} + } + return out + }, + } + + if r, err := m.ListObjects(ctx, "b", "", 0, ""); err != nil || r.Count != 1 { + t.Errorf("ListObjects = (%+v, %v)", r, err) + } + if _, err := m.UploadObject(ctx, "b", "k", strings.NewReader(""), ""); err != nil { + t.Errorf("UploadObject: %v", err) + } + if _, err := m.CreateDirectoryMarker(ctx, "b", "k/"); err != nil { + t.Errorf("CreateDirectoryMarker: %v", err) + } + if _, _, err := m.GetObject(ctx, "b", "k"); err != nil { + t.Errorf("GetObject: %v", err) + } + if ok, err := m.ObjectExists(ctx, "b", "k"); err != nil || !ok { + t.Errorf("ObjectExists = (%v, %v)", ok, err) + } + if err := m.DeleteObject(ctx, "b", "k"); err != nil { + t.Errorf("DeleteObject: %v", err) + } + if _, err := m.GetObjectMetadata(ctx, "b", "k"); err != nil { + t.Errorf("GetObjectMetadata: %v", err) + } + if u, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err != nil || u == "" { + t.Errorf("GetPresignedURL = (%q, %v)", u, err) + } + if err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err != nil { + t.Errorf("DeleteMultipleObjects: %v", err) + } + results := m.UploadMultipleObjects(ctx, "b", []struct { + Key string + Body io.Reader + ContentType string + }{{Key: "a"}}) + if len(results) != 1 || !results[0].Success { + t.Errorf("UploadMultipleObjects results = %+v", results) + } +} diff --git a/backend/internal/services/s3_minio_test.go b/backend/internal/services/s3_minio_test.go new file mode 100644 index 0000000..12e7817 --- /dev/null +++ b/backend/internal/services/s3_minio_test.go @@ -0,0 +1,569 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/pkg/utils" +) + +// s3ErrorXML writes an S3-style error response that the MinIO SDK parses. +func s3ErrorXML(w http.ResponseWriter, status int, code, msg string) { + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, ` +%s%s/x`, code, msg) +} + +// newS3TestService builds an S3Service whose Endpoint points at a single +// httptest.Server handling BOTH Garage admin calls (for credential lookup) +// and S3 data-plane requests. Admin requests are routed by path prefix +// `/v2/` and `/health`; everything else is treated as an S3 call and +// dispatched to s3Handler. +func newS3TestService(t *testing.T, s3Handler http.Handler) *S3Service { + t.Helper() + + secret := "s3-test-secret" + adminMux := http.NewServeMux() + adminMux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ + ID: "bid", + Keys: []models.BucketKeyInfo{ + {AccessKeyID: "TESTAK", Permissions: models.BucketKeyPermission{Read: true, Write: true}}, + }, + }) + }) + adminMux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{ + AccessKeyID: "TESTAK", + SecretAccessKey: &secret, + }) + }) + + combined := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/v2/") || r.URL.Path == "/health" { + adminMux.ServeHTTP(w, r) + return + } + s3Handler.ServeHTTP(w, r) + }) + + srv := httptest.NewServer(combined) + t.Cleanup(srv.Close) + + admin := NewGarageAdminService(&config.GarageConfig{ + AdminEndpoint: srv.URL, + AdminToken: "test", + }, "") + + // strip scheme for S3 endpoint (NewS3Service does this itself if http:// prefix) + s3 := NewS3Service(&config.GarageConfig{ + Endpoint: srv.URL, // http://127.0.0.1:NNNN + Region: "garage", + }, admin) + return s3 +} + +// uniqueBucket2 returns a per-test bucket name so GlobalCache doesn't leak +// credentials between tests. +func uniqueBucket2(t *testing.T) string { + t.Helper() + name := "b-" + strings.ReplaceAll(t.Name(), "/", "-") + t.Cleanup(func() { utils.GlobalCache.Delete("key:" + name) }) + return name +} + +// fixedRequestCounter returns an http.Handler that always replies with the +// given S3 error, and counts requests. +func errS3Handler(status int, code string) (http.Handler, *int) { + var count int + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count++ + s3ErrorXML(w, status, code, code) + }), &count +} + +func TestS3_ListBuckets_ServerError(t *testing.T) { + h, _ := errS3Handler(http.StatusInternalServerError, "InternalError") + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := s3.ListBuckets(ctx) + if err == nil { + t.Fatal("expected error from ListBuckets, got nil") + } + if !strings.Contains(err.Error(), "failed to list buckets") { + t.Errorf("error %v should wrap 'failed to list buckets'", err) + } +} + +func TestS3_CreateBucket_ServerError(t *testing.T) { + h, _ := errS3Handler(http.StatusConflict, "BucketAlreadyExists") + s3 := newS3TestService(t, h) + _ = uniqueBucket2(t) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := s3.CreateBucket(ctx, "b-TestS3_CreateBucket_ServerError") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to create bucket") { + t.Errorf("error = %v, want wrap 'failed to create bucket'", err) + } +} + +func TestS3_DeleteBucket_ServerError(t *testing.T) { + h, _ := errS3Handler(http.StatusNotFound, "NoSuchBucket") + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := s3.DeleteBucket(ctx, "b-TestS3_DeleteBucket_ServerError") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to delete bucket") { + t.Errorf("error = %v", err) + } +} + +func TestS3_ListObjects_ServerError(t *testing.T) { + h, _ := errS3Handler(http.StatusForbidden, "AccessDenied") + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_ServerError", "", 0, "") + if err == nil { + t.Fatal("expected error from ListObjects, got nil") + } + if !strings.Contains(err.Error(), "failed to list objects") { + t.Errorf("error = %v", err) + } +} + +func TestS3_UploadObject_ServerError(t *testing.T) { + h, _ := errS3Handler(http.StatusForbidden, "AccessDenied") + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := s3.UploadObject(ctx, "b-TestS3_UploadObject_ServerError", "k", bytes.NewReader([]byte("hi")), "text/plain") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to upload object") { + t.Errorf("error = %v", err) + } +} + +func TestS3_CreateDirectoryMarker_ServerError(t *testing.T) { + h, _ := errS3Handler(http.StatusForbidden, "AccessDenied") + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := s3.CreateDirectoryMarker(ctx, "b-TestS3_CreateDirectoryMarker_ServerError", "folder/") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to create directory") { + t.Errorf("error = %v", err) + } +} + +func TestS3_GetObject_ServerError(t *testing.T) { + h, _ := errS3Handler(http.StatusNotFound, "NoSuchKey") + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, _, err := s3.GetObject(ctx, "b-TestS3_GetObject_ServerError", "missing") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestS3_DeleteObject_ServerError(t *testing.T) { + h, _ := errS3Handler(http.StatusForbidden, "AccessDenied") + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := s3.DeleteObject(ctx, "b-TestS3_DeleteObject_ServerError", "k") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to delete object") { + t.Errorf("error = %v", err) + } +} + +func TestS3_ObjectExists_NoSuchKeyReturnsFalseNil(t *testing.T) { + h, _ := errS3Handler(http.StatusNotFound, "NoSuchKey") + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + exists, err := s3.ObjectExists(ctx, "b-TestS3_ObjectExists_NoSuchKeyReturnsFalseNil", "k") + if err != nil { + t.Fatalf("ObjectExists returned error for NoSuchKey: %v", err) + } + if exists { + t.Error("exists should be false for NoSuchKey") + } +} + +func TestS3_ObjectExists_OtherErrorPropagates(t *testing.T) { + h, _ := errS3Handler(http.StatusForbidden, "AccessDenied") + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := s3.ObjectExists(ctx, "b-TestS3_ObjectExists_OtherErrorPropagates", "k") + if err == nil { + t.Fatal("expected error for AccessDenied, got nil") + } +} + +func TestS3_GetObjectMetadata_ServerError(t *testing.T) { + h, _ := errS3Handler(http.StatusNotFound, "NoSuchKey") + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := s3.GetObjectMetadata(ctx, "b-TestS3_GetObjectMetadata_ServerError", "k") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to get metadata") { + t.Errorf("error = %v", err) + } +} + +func TestS3_DeleteMultipleObjects_EmptyKeysIsNoop(t *testing.T) { + // No S3 handler should be called; use a handler that fails if invoked. + called := false + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + s3ErrorXML(w, http.StatusInternalServerError, "ShouldNotHappen", "") + }) + s3 := newS3TestService(t, h) + + if err := s3.DeleteMultipleObjects(context.Background(), "whatever", nil); err != nil { + t.Fatalf("empty keys should return nil, got %v", err) + } + if called { + t.Error("S3 handler was invoked for empty-keys call") + } +} + +func TestS3_DeleteMultipleObjects_ServerErrorPropagates(t *testing.T) { + h, _ := errS3Handler(http.StatusForbidden, "AccessDenied") + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := s3.DeleteMultipleObjects(ctx, "b-TestS3_DeleteMultipleObjects_ServerErrorPropagates", []string{"a", "b"}) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestS3_GetPresignedURL_ReturnsURLWithoutServerCall(t *testing.T) { + // Presign is purely local (no network round-trip). Any handler suffices. + called := false + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + }) + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + got, err := s3.GetPresignedURL(ctx, "b-TestS3_GetPresignedURL_ReturnsURLWithoutServerCall", "k", 10*time.Minute) + if err != nil { + t.Fatalf("GetPresignedURL: %v", err) + } + u, perr := url.Parse(got) + if perr != nil { + t.Fatalf("returned URL is not parseable: %v", perr) + } + if u.Scheme == "" || u.Host == "" { + t.Errorf("presigned URL missing scheme/host: %q", got) + } + if !strings.Contains(u.RawQuery, "X-Amz-Signature") { + t.Errorf("presigned URL should contain X-Amz-Signature, got %q", got) + } + if called { + t.Error("presign should not make a network call") + } +} + +// listBucketResultXML produces a ListBucketResult XML body that MinIO +// parses. ListObjectsV2 is keyed on the `list-type=2` query parameter. +func listBucketResultXML(bucket string, isTruncated bool, nextToken string, contents []struct { + Key string + Size int64 + LastModified string + ETag string +}, commonPrefixes []string) string { + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + fmt.Fprintf(&b, `%s`, bucket) + b.WriteString(``) + fmt.Fprintf(&b, `%d`, len(contents)) + b.WriteString(`1000`) + fmt.Fprintf(&b, `%t`, isTruncated) + if nextToken != "" { + fmt.Fprintf(&b, `%s`, nextToken) + } + for _, c := range contents { + lm := c.LastModified + if lm == "" { + lm = "2024-01-01T00:00:00.000Z" + } + etag := c.ETag + if etag == "" { + etag = "d41d8cd98f00b204e9800998ecf8427e" + } + fmt.Fprintf(&b, `%s%s"%s"%dSTANDARD`, + c.Key, lm, etag, c.Size) + } + for _, p := range commonPrefixes { + fmt.Fprintf(&b, `%s`, p) + } + b.WriteString(``) + return b.String() +} + +// s3ListHandler routes ListObjectsV2 (GET with list-type=2) to listBody +// and StatObject (HEAD) to a 200 response whose headers reflect statHeaders. +func s3ListHandler(listBody string, statHeaders map[string]string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + for k, v := range statHeaders { + w.Header().Set(k, v) + } + if _, ok := statHeaders["Content-Length"]; !ok { + w.Header().Set("Content-Length", "0") + } + w.WriteHeader(http.StatusOK) + return + } + // Treat any GET as a ListObjectsV2 request. + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, listBody) + }) +} + +func TestS3_ListObjects_EmptyResult(t *testing.T) { + xml := listBucketResultXML("b", false, "", nil, nil) + s3 := newS3TestService(t, s3ListHandler(xml, nil)) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_EmptyResult", "", 0, "") + if err != nil { + t.Fatalf("ListObjects: %v", err) + } + if got.Count != 0 || len(got.Objects) != 0 || len(got.Prefixes) != 0 { + t.Errorf("expected empty listing, got %+v", got) + } +} + +func TestS3_ListObjects_ObjectsAndPrefixes(t *testing.T) { + contents := []struct { + Key string + Size int64 + LastModified string + ETag string + }{ + {Key: "file.txt", Size: 10}, + } + xml := listBucketResultXML("b", false, "", contents, []string{"folder/"}) + s3 := newS3TestService(t, s3ListHandler(xml, map[string]string{ + "Content-Type": "text/plain", + "Content-Length": "10", + })) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_ObjectsAndPrefixes", "", 0, "") + if err != nil { + t.Fatalf("ListObjects: %v", err) + } + if got.Count != 1 || got.Objects[0].Key != "file.txt" { + t.Errorf("objects = %+v", got.Objects) + } + if len(got.Prefixes) != 1 || got.Prefixes[0] != "folder/" { + t.Errorf("prefixes = %+v", got.Prefixes) + } + // ContentType from StatObject may or may not round-trip depending on + // signature validation in the MinIO client; don't assert on it here. +} + +func TestS3_ListObjects_DirectoryMarkerPromotedToPrefix(t *testing.T) { + // A zero-byte key ending in "/" in Contents must be dropped from + // Objects and promoted to Prefixes (unless already covered). + contents := []struct { + Key string + Size int64 + LastModified string + ETag string + }{ + {Key: "empty-folder/", Size: 0}, + {Key: "already/", Size: 0}, // duplicate of CommonPrefix — must not duplicate + {Key: "real.txt", Size: 5}, + } + xml := listBucketResultXML("b", true, "tokenXYZ", contents, []string{"already/"}) + s3 := newS3TestService(t, s3ListHandler(xml, map[string]string{"Content-Length": "5"})) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_DirectoryMarkerPromotedToPrefix", "", 0, "") + if err != nil { + t.Fatalf("ListObjects: %v", err) + } + if got.Count != 1 || got.Objects[0].Key != "real.txt" { + t.Errorf("Objects should contain only real.txt, got %+v", got.Objects) + } + // Prefixes should contain "already/" (from CommonPrefix) and "empty-folder/" + // (promoted from Contents). "already/" must not appear twice. + count := map[string]int{} + for _, p := range got.Prefixes { + count[p]++ + } + if count["already/"] != 1 { + t.Errorf("Prefixes contains 'already/' %d times, want 1: %v", count["already/"], got.Prefixes) + } + if count["empty-folder/"] != 1 { + t.Errorf("Prefixes missing 'empty-folder/': %v", got.Prefixes) + } + if !got.IsTruncated || got.NextContinuationToken != "tokenXYZ" { + t.Errorf("pagination fields not propagated: IsTruncated=%v Token=%q", got.IsTruncated, got.NextContinuationToken) + } +} + +func TestS3_ListObjects_MarkerMatchingPrefixIsDropped(t *testing.T) { + // When listing a specific prefix, a marker whose key == the listing + // prefix is the folder itself — it must not render as a child of itself. + contents := []struct { + Key string + Size int64 + LastModified string + ETag string + }{ + {Key: "mydir/", Size: 0}, // matches prefix — must be dropped entirely + } + xml := listBucketResultXML("b", false, "", contents, nil) + s3 := newS3TestService(t, s3ListHandler(xml, nil)) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_MarkerMatchingPrefixIsDropped", "mydir/", 0, "") + if err != nil { + t.Fatalf("ListObjects: %v", err) + } + if len(got.Objects) != 0 || len(got.Prefixes) != 0 { + t.Errorf("marker equal to prefix should be dropped entirely, got %+v", got) + } +} + +func TestS3_ListObjects_StatObjectFailureLeavesContentTypeEmpty(t *testing.T) { + contents := []struct { + Key string + Size int64 + LastModified string + ETag string + }{ + {Key: "f.bin", Size: 100}, + } + xml := listBucketResultXML("b", false, "", contents, nil) + + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + // StatObject fails — return 403. + w.WriteHeader(http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, xml) + }) + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_StatObjectFailureLeavesContentTypeEmpty", "", 0, "") + if err != nil { + t.Fatalf("ListObjects: %v", err) + } + if got.Count != 1 || got.Objects[0].Key != "f.bin" { + t.Fatalf("unexpected object list %+v", got.Objects) + } + if got.Objects[0].ContentType != "" { + t.Errorf("ContentType = %q, want empty on StatObject failure", got.Objects[0].ContentType) + } +} + +func TestS3_UploadMultipleObjects_PerFileFailuresRecorded(t *testing.T) { + h, _ := errS3Handler(http.StatusForbidden, "AccessDenied") + s3 := newS3TestService(t, h) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + results := s3.UploadMultipleObjects(ctx, "b-TestS3_UploadMultipleObjects_PerFileFailuresRecorded", []struct { + Key string + Body io.Reader + ContentType string + }{ + {Key: "a", Body: bytes.NewReader([]byte("hello")), ContentType: "text/plain"}, + {Key: "b", Body: bytes.NewReader([]byte("world")), ContentType: "text/plain"}, + }) + + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2", len(results)) + } + for i, r := range results { + if r.Success { + t.Errorf("result[%d] should not be successful: %+v", i, r) + } + if r.Error == nil { + t.Errorf("result[%d] should have Error set", i) + } + } +} diff --git a/backend/pkg/utils/retry_test.go b/backend/pkg/utils/retry_test.go index 7166b09..6b4399c 100644 --- a/backend/pkg/utils/retry_test.go +++ b/backend/pkg/utils/retry_test.go @@ -266,3 +266,19 @@ func containsAll(s string, subs ...string) bool { } return true } + +func TestDefaultRetryConfig(t *testing.T) { + c := DefaultRetryConfig() + if c.MaxRetries <= 0 { + t.Errorf("MaxRetries = %d, want >0", c.MaxRetries) + } + if c.InitialBackoff <= 0 { + t.Errorf("InitialBackoff = %v, want >0", c.InitialBackoff) + } + if c.MaxBackoff < c.InitialBackoff { + t.Errorf("MaxBackoff (%v) should be >= InitialBackoff (%v)", c.MaxBackoff, c.InitialBackoff) + } + if c.BackoffFactor < 1.0 { + t.Errorf("BackoffFactor = %v, want >=1.0", c.BackoffFactor) + } +}