From 09289371a2073df539e1f935ad9b00786f075069 Mon Sep 17 00:00:00 2001
From: Noooste <83548733+Noooste@users.noreply.github.com>
Date: Sun, 19 Apr 2026 18:11:21 +0200
Subject: [PATCH] test: add unit tests for CreateDirectory endpoint and related
S3 functionality
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
---
.github/workflows/test.yml | 9 +
backend/internal/auth/auth_more_test.go | 267 ++++++++
backend/internal/handlers/objects_test.go | 98 +++
backend/internal/models/responses_test.go | 47 ++
backend/internal/routes/routes_more_test.go | 254 ++++++++
backend/internal/services/mocks/mocks_test.go | 214 +++++++
backend/internal/services/s3_minio_test.go | 569 ++++++++++++++++++
backend/pkg/utils/retry_test.go | 16 +
8 files changed, 1474 insertions(+)
create mode 100644 backend/internal/auth/auth_more_test.go
create mode 100644 backend/internal/models/responses_test.go
create mode 100644 backend/internal/routes/routes_more_test.go
create mode 100644 backend/internal/services/mocks/mocks_test.go
create mode 100644 backend/internal/services/s3_minio_test.go
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)
+ }
+}