package services import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/models" "Noooste/garage-ui/pkg/utils" ) func TestNewS3Service_StripsHTTPPrefix(t *testing.T) { cfg := &config.GarageConfig{ Endpoint: "http://garage:3900", Region: "garage", } _ = NewS3Service(cfg, nil) if cfg.Endpoint != "garage:3900" { t.Errorf("Endpoint = %q, want %q", cfg.Endpoint, "garage:3900") } if cfg.UseSSL { t.Error("UseSSL should remain false for http://") } } func TestNewS3Service_StripsHTTPSPrefixAndEnablesSSL(t *testing.T) { cfg := &config.GarageConfig{ Endpoint: "https://garage.example.com", Region: "garage", } _ = NewS3Service(cfg, nil) if cfg.Endpoint != "garage.example.com" { t.Errorf("Endpoint = %q", cfg.Endpoint) } if !cfg.UseSSL { t.Error("UseSSL should be flipped to true for https://") } } func TestNewS3Service_LeavesBareHostUnchanged(t *testing.T) { cfg := &config.GarageConfig{ Endpoint: "garage:3900", Region: "garage", UseSSL: true, // pre-set; should remain true } _ = NewS3Service(cfg, nil) if cfg.Endpoint != "garage:3900" { t.Errorf("Endpoint mutated unexpectedly: %q", cfg.Endpoint) } if !cfg.UseSSL { t.Error("pre-set UseSSL must be preserved") } } // adminBackedS3 wires an S3Service to a fresh GarageAdminService that talks // to the supplied http.Handler. func adminBackedS3(t *testing.T, handler http.Handler) (*S3Service, *httptest.Server) { t.Helper() srv := httptest.NewServer(handler) t.Cleanup(srv.Close) admin := NewGarageAdminService(&config.GarageConfig{ AdminEndpoint: srv.URL, AdminToken: "test-token", }, "") s3 := NewS3Service(&config.GarageConfig{ Endpoint: "garage:3900", Region: "garage", }, admin) return s3, srv } // uniqueBucket returns a per-subtest bucket name so cached credentials from // one test don't leak into another via utils.GlobalCache. func uniqueBucket(t *testing.T) string { t.Helper() name := "test-bucket-" + t.Name() t.Cleanup(func() { utils.GlobalCache.Delete("key:" + name) }) return name } func TestGetBucketCredentials_HappyPath(t *testing.T) { bucket := uniqueBucket(t) secret := "the-secret" mux := http.NewServeMux() mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ ID: "bid", Keys: []models.BucketKeyInfo{ { AccessKeyID: "AK", Permissions: models.BucketKeyPermission{Read: true, Write: true}, }, }, }) }) mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{ AccessKeyID: "AK", SecretAccessKey: &secret, }) }) s3, _ := adminBackedS3(t, mux) creds, err := s3.getBucketCredentials(context.Background(), bucket) if err != nil { t.Fatalf("getBucketCredentials: %v", err) } if creds == nil { t.Fatal("creds is nil") } v, err := creds.GetWithContext(nil) if err != nil { t.Fatalf("creds.GetWithContext: %v", err) } if v.AccessKeyID != "AK" || v.SecretAccessKey != secret { t.Errorf("got AK=%q SK=%q, want AK SK=%q", v.AccessKeyID, v.SecretAccessKey, secret) } } func TestGetBucketCredentials_CachesAcrossCalls(t *testing.T) { bucket := uniqueBucket(t) secret := "cache-secret" var bucketCalls, keyCalls int mux := http.NewServeMux() mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { bucketCalls++ _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ ID: "bid", Keys: []models.BucketKeyInfo{ {AccessKeyID: "AK", Permissions: models.BucketKeyPermission{Read: true, Write: true}}, }, }) }) mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { keyCalls++ _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{ AccessKeyID: "AK", SecretAccessKey: &secret, }) }) s3, _ := adminBackedS3(t, mux) for i := range 3 { if _, err := s3.getBucketCredentials(context.Background(), bucket); err != nil { t.Fatalf("call %d: %v", i, err) } } if bucketCalls != 1 { t.Errorf("GetBucketInfo called %d times, want 1 (cache should serve calls 2-3)", bucketCalls) } if keyCalls != 1 { t.Errorf("GetKeyInfo called %d times, want 1", keyCalls) } } func TestGetBucketCredentials_SkipsKeysWithoutReadOrWrite(t *testing.T) { bucket := uniqueBucket(t) secret := "good-secret" mux := http.NewServeMux() mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ ID: "bid", Keys: []models.BucketKeyInfo{ {AccessKeyID: "READ-ONLY", Permissions: models.BucketKeyPermission{Read: true, Write: false}}, {AccessKeyID: "WRITE-ONLY", Permissions: models.BucketKeyPermission{Read: false, Write: true}}, {AccessKeyID: "NO-PERMS", Permissions: models.BucketKeyPermission{}}, {AccessKeyID: "RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}}, }, }) }) mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("id") != "RW" { t.Errorf("GetKeyInfo called with id=%q, want RW", r.URL.Query().Get("id")) } _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{ AccessKeyID: "RW", SecretAccessKey: &secret, }) }) s3, _ := adminBackedS3(t, mux) creds, err := s3.getBucketCredentials(context.Background(), bucket) if err != nil { t.Fatalf("getBucketCredentials: %v", err) } v, err := creds.GetWithContext(nil) if err != nil { t.Fatalf("creds.GetWithContext: %v", err) } if v.AccessKeyID != "RW" { t.Errorf("AccessKeyID = %q, want RW", v.AccessKeyID) } } func TestGetBucketCredentials_NoEligibleKeyReturnsError(t *testing.T) { bucket := uniqueBucket(t) mux := http.NewServeMux() mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ ID: "bid", Keys: []models.BucketKeyInfo{}, // no keys at all }) }) mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { t.Error("GetKeyInfo should not be called when no keys exist") }) s3, _ := adminBackedS3(t, mux) _, err := s3.getBucketCredentials(context.Background(), bucket) if err == nil { t.Fatal("expected error when bucket has no keys, got nil") } if !strings.Contains(err.Error(), "no valid credentials") { t.Errorf("expected 'no valid credentials' in error, got %v", err) } } func TestGetBucketCredentials_KeyWithoutSecretIsSkipped(t *testing.T) { bucket := uniqueBucket(t) secret := "good" mux := http.NewServeMux() mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ ID: "bid", Keys: []models.BucketKeyInfo{ {AccessKeyID: "FIRST-RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}}, {AccessKeyID: "SECOND-RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}}, }, }) }) mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { switch r.URL.Query().Get("id") { case "FIRST-RW": _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{AccessKeyID: "FIRST-RW", SecretAccessKey: nil}) case "SECOND-RW": _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{AccessKeyID: "SECOND-RW", SecretAccessKey: &secret}) default: t.Errorf("unexpected GetKeyInfo id: %q", r.URL.Query().Get("id")) } }) s3, _ := adminBackedS3(t, mux) creds, err := s3.getBucketCredentials(context.Background(), bucket) if err != nil { t.Fatalf("getBucketCredentials: %v", err) } v, err := creds.GetWithContext(nil) if err != nil { t.Fatalf("creds.GetWithContext: %v", err) } if v.AccessKeyID != "SECOND-RW" { t.Errorf("AccessKeyID = %q, want SECOND-RW (loop must skip FIRST-RW's nil secret)", v.AccessKeyID) } } func TestGetBucketCredentials_AdminErrorPropagates(t *testing.T) { bucket := uniqueBucket(t) mux := http.NewServeMux() mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"boom"}`)) }) s3, _ := adminBackedS3(t, mux) _, err := s3.getBucketCredentials(context.Background(), bucket) if err == nil { t.Fatal("expected error when admin call fails, got nil") } if !strings.Contains(err.Error(), "failed to get bucket info") { t.Errorf("expected wrapped 'failed to get bucket info' error, got %v", err) } } func TestGetBucketStatistics_HappyPath(t *testing.T) { bucket := uniqueBucket(t) mux := http.NewServeMux() mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.URL.RawQuery, "globalAlias=") { t.Errorf("expected globalAlias query, got %q", r.URL.RawQuery) } _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ ID: "bid", Objects: 42, Bytes: 123_456, }) }) s3, _ := adminBackedS3(t, mux) stats, err := s3.GetBucketStatistics(context.Background(), bucket) if err != nil { t.Fatalf("GetBucketStatistics: %v", err) } if stats.ObjectCount != 42 || stats.TotalSize != 123_456 { t.Errorf("got %+v, want ObjectCount=42 TotalSize=123456", stats) } } func TestGetBucketStatistics_AdminErrorPropagates(t *testing.T) { bucket := uniqueBucket(t) mux := http.NewServeMux() mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"no such bucket"}`)) }) s3, _ := adminBackedS3(t, mux) _, err := s3.GetBucketStatistics(context.Background(), bucket) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "failed to get bucket info") { t.Errorf("expected 'failed to get bucket info' wrap, got %v", err) } }