mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-13 06:56:54 +00:00
test: add unit tests for CreateDirectory endpoint and related S3 functionality
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error><Code>%s</Code><Message>%s</Message><Resource>/</Resource><RequestId>x</RequestId></Error>`, 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(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
b.WriteString(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">`)
|
||||
fmt.Fprintf(&b, `<Name>%s</Name>`, bucket)
|
||||
b.WriteString(`<Prefix></Prefix>`)
|
||||
fmt.Fprintf(&b, `<KeyCount>%d</KeyCount>`, len(contents))
|
||||
b.WriteString(`<MaxKeys>1000</MaxKeys>`)
|
||||
fmt.Fprintf(&b, `<IsTruncated>%t</IsTruncated>`, isTruncated)
|
||||
if nextToken != "" {
|
||||
fmt.Fprintf(&b, `<NextContinuationToken>%s</NextContinuationToken>`, 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, `<Contents><Key>%s</Key><LastModified>%s</LastModified><ETag>"%s"</ETag><Size>%d</Size><StorageClass>STANDARD</StorageClass></Contents>`,
|
||||
c.Key, lm, etag, c.Size)
|
||||
}
|
||||
for _, p := range commonPrefixes {
|
||||
fmt.Fprintf(&b, `<CommonPrefixes><Prefix>%s</Prefix></CommonPrefixes>`, p)
|
||||
}
|
||||
b.WriteString(`</ListBucketResult>`)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user