mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
test: add comprehensive test suite for logging, request ID, and user management
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// newAuthTestService builds a real auth.Service with OIDC disabled. The JWT
|
||||
// key is auto-generated, matching the production default.
|
||||
func newAuthTestService(t *testing.T, admin config.AdminAuthConfig) *auth.Service {
|
||||
t.Helper()
|
||||
svc, err := auth.NewAuthService(
|
||||
&config.AuthConfig{
|
||||
Admin: admin,
|
||||
OIDC: config.OIDCConfig{Enabled: false},
|
||||
},
|
||||
&config.ServerConfig{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthService: %v", err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newAuthTestApp builds a bare Fiber app with the auth handler mounted.
|
||||
// The admin and OIDC config are reflected both in cfg (for the handler) and
|
||||
// in the auth service.
|
||||
func newAuthTestApp(t *testing.T, cfg *config.Config) (*fiber.App, *AuthHandler) {
|
||||
t.Helper()
|
||||
svc := newAuthTestService(t, cfg.Auth.Admin)
|
||||
h := NewAuthHandler(cfg, svc)
|
||||
app := fiber.New()
|
||||
app.Get("/auth/config", h.GetAuthConfig)
|
||||
app.Post("/auth/login", h.LoginAdmin)
|
||||
app.Get("/auth/me", h.GetMe)
|
||||
return app, h
|
||||
}
|
||||
|
||||
func TestGetAuthConfig_AdminOnly(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "p"},
|
||||
OIDC: config.OIDCConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/config", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Admin struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"admin"`
|
||||
OIDC struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Provider string `json:"provider"`
|
||||
} `json:"oidc"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !body.Admin.Enabled {
|
||||
t.Error("admin.enabled = false, want true")
|
||||
}
|
||||
if body.OIDC.Enabled {
|
||||
t.Error("oidc.enabled = true, want false")
|
||||
}
|
||||
if body.OIDC.Provider != "" {
|
||||
t.Errorf("oidc.provider = %q, want empty", body.OIDC.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthConfig_OIDCOnly_WithExplicitProvider(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: false},
|
||||
OIDC: config.OIDCConfig{
|
||||
Enabled: false, // service init skipped (newAuthTestService disables OIDC); handler only reads flags
|
||||
ProviderName: "Keycloak",
|
||||
},
|
||||
},
|
||||
}
|
||||
// Re-enable OIDC only on the cfg the handler sees — the service is still
|
||||
// constructed with OIDC disabled above, which is fine because
|
||||
// GetAuthConfig does not touch the service at all.
|
||||
cfg.Auth.OIDC.Enabled = true
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/config", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
OIDC struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Provider string `json:"provider"`
|
||||
} `json:"oidc"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !body.OIDC.Enabled {
|
||||
t.Error("oidc.enabled = false, want true")
|
||||
}
|
||||
if body.OIDC.Provider != "Keycloak" {
|
||||
t.Errorf("oidc.provider = %q, want Keycloak", body.OIDC.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthConfig_OIDCEnabled_DefaultProviderName(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: false},
|
||||
OIDC: config.OIDCConfig{Enabled: true, ProviderName: ""},
|
||||
},
|
||||
}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/config", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var body struct {
|
||||
OIDC struct {
|
||||
Provider string `json:"provider"`
|
||||
} `json:"oidc"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if body.OIDC.Provider != "OIDC Provider" {
|
||||
t.Errorf("provider = %q, want default 'OIDC Provider'", body.OIDC.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAdmin_HappyPath(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "s3cret"},
|
||||
},
|
||||
}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"username": "admin", "password": "s3cret"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d, want 200\nbody: %s", resp.StatusCode, raw)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Success bool `json:"success"`
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !decoded.Success {
|
||||
t.Error("success = false")
|
||||
}
|
||||
if decoded.Token == "" {
|
||||
t.Error("token empty")
|
||||
}
|
||||
if decoded.User.Username != "admin" {
|
||||
t.Errorf("username = %q, want admin", decoded.User.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAdmin_WrongPasswordReturns401(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "s3cret"}},
|
||||
}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
body, _ := json.Marshal(map[string]string{"username": "admin", "password": "WRONG"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAdmin_WrongUsernameReturns401(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "s3cret"}},
|
||||
}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
body, _ := json.Marshal(map[string]string{"username": "root", "password": "s3cret"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAdmin_MalformedJSONReturns400(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "p"}},
|
||||
}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader("{not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMe_OIDCUserInfoLocal(t *testing.T) {
|
||||
cfg := &config.Config{Auth: config.AuthConfig{}}
|
||||
app, h := newAuthTestApp(t, cfg)
|
||||
// Re-register /auth/me with a pre-handler that seeds c.Locals("userInfo").
|
||||
// The default registration in newAuthTestApp lacks Locals; we mount a
|
||||
// second path that does.
|
||||
app.Get("/me-oidc", func(c fiber.Ctx) error {
|
||||
c.Locals("userInfo", &auth.UserInfo{
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
Name: "Alice Example",
|
||||
})
|
||||
return h.GetMe(c)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/me-oidc", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var decoded struct {
|
||||
Success bool `json:"success"`
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if decoded.User.Username != "alice" || decoded.User.Email != "alice@example.com" || decoded.User.Name != "Alice Example" {
|
||||
t.Errorf("got %+v", decoded.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMe_BasicAuthUsernameLocal(t *testing.T) {
|
||||
cfg := &config.Config{Auth: config.AuthConfig{}}
|
||||
app, h := newAuthTestApp(t, cfg)
|
||||
app.Get("/me-basic", func(c fiber.Ctx) error {
|
||||
c.Locals("username", "admin")
|
||||
return h.GetMe(c)
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/me-basic", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var decoded struct {
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&decoded)
|
||||
if decoded.User.Username != "admin" {
|
||||
t.Errorf("username = %q, want admin", decoded.User.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMe_NoLocalsReturns401(t *testing.T) {
|
||||
cfg := &config.Config{Auth: config.AuthConfig{}}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/me", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newBucketsTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
|
||||
t.Helper()
|
||||
admin := &mocks.AdminMock{}
|
||||
h := NewBucketHandler(admin, nil) // s3 unused in this handler
|
||||
app := fiber.New()
|
||||
app.Get("/buckets", h.ListBuckets)
|
||||
app.Post("/buckets", h.CreateBucket)
|
||||
app.Get("/buckets/:name", h.GetBucketInfo)
|
||||
app.Delete("/buckets/:name", h.DeleteBucket)
|
||||
app.Post("/buckets/:name/permissions", h.GrantBucketPermission)
|
||||
app.Put("/buckets/:name/website", h.UpdateBucketWebsite)
|
||||
return app, admin
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, r io.Reader, v any) {
|
||||
t.Helper()
|
||||
if err := json.NewDecoder(r).Decode(v); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ListBuckets ---
|
||||
|
||||
func TestListBuckets_MapsAliasesAndStats(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return []models.ListBucketsResponseItem{
|
||||
{ID: "id-1", Created: time.Unix(0, 0), GlobalAliases: []string{"alpha"}},
|
||||
{ID: "id-2", Created: time.Unix(0, 0), GlobalAliases: []string{}}, // skipped: no global alias
|
||||
{ID: "id-3", Created: time.Unix(0, 0), GlobalAliases: []string{"gamma"}},
|
||||
}, nil
|
||||
}
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) {
|
||||
switch alias {
|
||||
case "alpha":
|
||||
return &models.GarageBucketInfo{ID: "id-1", Objects: 10, Bytes: 100, WebsiteAccess: true}, nil
|
||||
case "gamma":
|
||||
return nil, errors.New("detail fetch failed") // degraded path
|
||||
}
|
||||
return nil, errors.New("unexpected alias: " + alias)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/buckets", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.BucketListResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data.Count != 2 {
|
||||
t.Errorf("count = %d, want 2 (id-2 skipped)", body.Data.Count)
|
||||
}
|
||||
// alpha has stats; gamma degraded to no stats (ObjectCount/Size nil).
|
||||
var foundAlpha, foundGamma bool
|
||||
for _, b := range body.Data.Buckets {
|
||||
if b.Name == "alpha" {
|
||||
foundAlpha = true
|
||||
if b.ObjectCount == nil || *b.ObjectCount != 10 {
|
||||
t.Errorf("alpha.ObjectCount = %v, want 10", b.ObjectCount)
|
||||
}
|
||||
if !b.WebsiteAccess {
|
||||
t.Error("alpha.WebsiteAccess false")
|
||||
}
|
||||
}
|
||||
if b.Name == "gamma" {
|
||||
foundGamma = true
|
||||
if b.ObjectCount != nil {
|
||||
t.Errorf("gamma.ObjectCount = %v, want nil (degraded)", *b.ObjectCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundAlpha || !foundGamma {
|
||||
t.Errorf("missing buckets: alpha=%v gamma=%v", foundAlpha, foundGamma)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBuckets_AdminErrorReturns500(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- CreateBucket ---
|
||||
|
||||
func TestCreateBucket_Success201(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.CreateBucketFn = func(_ context.Context, r models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
|
||||
if r.GlobalAlias == nil || *r.GlobalAlias != "new-bucket" {
|
||||
t.Errorf("GlobalAlias = %v, want 'new-bucket'", r.GlobalAlias)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "id-new"}, nil
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"name": "new-bucket"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBucket_MissingNameReturns400(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
body, _ := json.Marshal(map[string]string{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBucket_MalformedJSONReturns400(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets", strings.NewReader("{not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBucket_AdminErrorReturns500(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.CreateBucketFn = func(_ context.Context, _ models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"name": "x"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetBucketInfo ---
|
||||
|
||||
func TestGetBucketInfo_Success(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1", Bytes: 1, Objects: 1}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/alpha", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketInfo_NotFound404(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return nil, nil // nil pointer, nil error → 404
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/missing", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketInfo_ServiceErrorReturns500(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/alpha", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeleteBucket ---
|
||||
|
||||
func TestDeleteBucket_Success(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.DeleteBucketFn = func(_ context.Context, id string) error {
|
||||
if id != "id-1" {
|
||||
t.Errorf("DeleteBucket id = %q, want id-1", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/alpha", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBucket_NotFound(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/missing", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBucket_AdminDeleteErrorReturns500(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.DeleteBucketFn = func(_ context.Context, _ string) error { return errors.New("boom") }
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/alpha", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GrantBucketPermission ---
|
||||
|
||||
func TestGrantBucketPermission_Success(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.AllowBucketKeyFn = func(_ context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.BucketID != "id-1" || req.AccessKeyID != "AKIA" {
|
||||
t.Errorf("req = %+v", req)
|
||||
}
|
||||
if !req.Permissions.Read || !req.Permissions.Write || req.Permissions.Owner {
|
||||
t.Errorf("perms = %+v", req.Permissions)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
body, _ := json.Marshal(models.GrantBucketPermissionRequest{
|
||||
AccessKeyID: "AKIA",
|
||||
Permissions: models.BucketKeyPermission{Read: true, Write: true},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/alpha/permissions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantBucketPermission_MissingAccessKey400(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
body, _ := json.Marshal(map[string]any{"accessKeyId": "", "permissions": map[string]bool{"read": true}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/alpha/permissions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantBucketPermission_BucketNotFound404(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
body, _ := json.Marshal(models.GrantBucketPermissionRequest{AccessKeyID: "AKIA"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/missing/permissions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- UpdateBucketWebsite ---
|
||||
|
||||
func TestUpdateBucketWebsite_EnableWithIndexDocument(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.WebsiteAccess == nil || !req.WebsiteAccess.Enabled {
|
||||
t.Errorf("WebsiteAccess = %+v", req.WebsiteAccess)
|
||||
}
|
||||
if req.WebsiteAccess.IndexDocument == nil || *req.WebsiteAccess.IndexDocument != "index.html" {
|
||||
t.Errorf("IndexDocument = %v", req.WebsiteAccess.IndexDocument)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: id, WebsiteAccess: true}, nil
|
||||
}
|
||||
body, _ := json.Marshal(models.UpdateBucketWebsiteRequest{Enabled: true, IndexDocument: "index.html"})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/website", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketWebsite_EnableWithoutIndexDocumentReturns400(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
body, _ := json.Marshal(models.UpdateBucketWebsiteRequest{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/website", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketWebsite_Disable(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.UpdateBucketFn = func(_ context.Context, _ string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.WebsiteAccess == nil || req.WebsiteAccess.Enabled {
|
||||
t.Errorf("expected Enabled=false, got %+v", req.WebsiteAccess)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
body, _ := json.Marshal(models.UpdateBucketWebsiteRequest{Enabled: false})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/website", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newClusterTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
|
||||
t.Helper()
|
||||
admin := &mocks.AdminMock{}
|
||||
h := NewClusterHandler(admin)
|
||||
app := fiber.New()
|
||||
app.Get("/cluster/health", h.GetHealth)
|
||||
app.Get("/cluster/status", h.GetStatus)
|
||||
app.Get("/cluster/statistics", h.GetStatistics)
|
||||
app.Get("/cluster/nodes/:node_id", h.GetNodeInfo)
|
||||
app.Get("/cluster/nodes/:node_id/statistics", h.GetNodeStatistics)
|
||||
// Extra routes without a node_id param so we can exercise the empty-id gate.
|
||||
// Fiber requires a bound param value, so we mount a path with an empty
|
||||
// trailing segment explicitly via Locals.
|
||||
app.Get("/cluster/nodes-empty", func(c fiber.Ctx) error {
|
||||
// Set empty node_id in locals via a route that doesn't capture it.
|
||||
return h.GetNodeInfo(c)
|
||||
})
|
||||
return app, admin
|
||||
}
|
||||
|
||||
func doGet(t *testing.T, app *fiber.App, path string) *http.Response {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test %s: %v", path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestCluster_GetHealth_Success(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetClusterHealthFn = func(_ context.Context) (*models.ClusterHealth, error) {
|
||||
return &models.ClusterHealth{Status: "healthy"}, nil
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/health")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Data models.ClusterHealth `json:"data"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if !body.Success || body.Data.Status != "healthy" {
|
||||
t.Errorf("body = %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetHealth_ServiceErrorReturns500(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetClusterHealthFn = func(_ context.Context) (*models.ClusterHealth, error) {
|
||||
return nil, errors.New("upstream down")
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/health")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetStatus_Success(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetClusterStatusFn = func(_ context.Context) (*models.ClusterStatus, error) {
|
||||
return &models.ClusterStatus{}, nil
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/status")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetStatus_ServiceErrorReturns500(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetClusterStatusFn = func(_ context.Context) (*models.ClusterStatus, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/status")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetStatistics_Success(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetClusterStatisticsFn = func(_ context.Context) (*models.ClusterStatistics, error) {
|
||||
return &models.ClusterStatistics{}, nil
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/statistics")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetStatistics_ServiceErrorReturns500(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetClusterStatisticsFn = func(_ context.Context) (*models.ClusterStatistics, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/statistics")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetNodeInfo_Success(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetNodeInfoFn = func(_ context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
if nodeID != "node-1" {
|
||||
t.Errorf("nodeID = %q, want node-1", nodeID)
|
||||
}
|
||||
return &models.MultiNodeResponse{}, nil
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/nodes/node-1")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetNodeInfo_ServiceErrorReturns500(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetNodeInfoFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/nodes/n1")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetNodeStatistics_Success(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetNodeStatisticsFn = func(_ context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
return &models.MultiNodeResponse{}, nil
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/nodes/n1/statistics")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetNodeStatistics_ServiceErrorReturns500(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetNodeStatisticsFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/nodes/n1/statistics")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// newHealthTestApp builds a bare Fiber app with the health endpoint mounted.
|
||||
func newHealthTestApp(t *testing.T, version string) *fiber.App {
|
||||
t.Helper()
|
||||
h := NewHealthHandler(version)
|
||||
app := fiber.New()
|
||||
app.Get("/health", h.Check)
|
||||
return app
|
||||
}
|
||||
|
||||
func TestHealthCheck_ReturnsHealthyEnvelope(t *testing.T) {
|
||||
app := newHealthTestApp(t, "v0.0.0-test")
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Version string `json:"version"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatalf("decode body: %v\n%s", err, body)
|
||||
}
|
||||
|
||||
if !envelope.Success {
|
||||
t.Error("success = false, want true")
|
||||
}
|
||||
if envelope.Data.Status != "healthy" {
|
||||
t.Errorf("status = %q, want healthy", envelope.Data.Status)
|
||||
}
|
||||
if envelope.Data.Version != "v0.0.0-test" {
|
||||
t.Errorf("version = %q, want v0.0.0-test", envelope.Data.Version)
|
||||
}
|
||||
if envelope.Data.Timestamp.IsZero() {
|
||||
t.Error("timestamp zero")
|
||||
}
|
||||
// Timestamp must be within a reasonable window (1 minute) of now.
|
||||
if d := time.Since(envelope.Data.Timestamp); d < 0 || d > time.Minute {
|
||||
t.Errorf("timestamp delta = %v, want within 1 minute of now", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newMonitoringTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
|
||||
t.Helper()
|
||||
admin := &mocks.AdminMock{}
|
||||
h := NewMonitoringHandler(admin, nil) // s3Service unused by this handler
|
||||
app := fiber.New()
|
||||
app.Get("/monitoring/metrics", h.GetMetrics)
|
||||
app.Get("/monitoring/admin-health", h.CheckAdminHealth)
|
||||
app.Get("/monitoring/dashboard", h.GetDashboardMetrics)
|
||||
return app, admin
|
||||
}
|
||||
|
||||
func TestMonitoring_GetMetrics_PassesThroughAsPlainText(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.GetMetricsFn = func(_ context.Context) (string, error) {
|
||||
return "# HELP foo\nfoo 1\n", nil
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/metrics", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "text/plain; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q", ct)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if string(b) != "# HELP foo\nfoo 1\n" {
|
||||
t.Errorf("body = %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_GetMetrics_ServiceErrorReturns500JSON(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.GetMetricsFn = func(_ context.Context) (string, error) {
|
||||
return "", errors.New("scrape failed")
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/metrics", nil)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_CheckAdminHealth_Healthy(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.HealthCheckFn = func(_ context.Context) error { return nil }
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/admin-health", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if !body.Success || body.Data.Status != "healthy" {
|
||||
t.Errorf("body = %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_CheckAdminHealth_Unhealthy503(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.HealthCheckFn = func(_ context.Context) error { return errors.New("down") }
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/admin-health", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_GetDashboardMetrics_AggregatesSizesAndPercentages(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return []models.ListBucketsResponseItem{
|
||||
{ID: "b1", Created: time.Unix(0, 0), GlobalAliases: []string{"alpha"}},
|
||||
{ID: "b2", Created: time.Unix(0, 0), GlobalAliases: []string{"beta"}},
|
||||
}, nil
|
||||
}
|
||||
admin.GetBucketInfoFn = func(_ context.Context, id string) (*models.GarageBucketInfo, error) {
|
||||
switch id {
|
||||
case "b1":
|
||||
return &models.GarageBucketInfo{ID: "b1", Bytes: 300, Objects: 3}, nil
|
||||
case "b2":
|
||||
return &models.GarageBucketInfo{ID: "b2", Bytes: 100, Objects: 1}, nil
|
||||
}
|
||||
return nil, errors.New("unexpected id: " + id)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data models.DashboardMetrics `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if body.Data.TotalSize != 400 {
|
||||
t.Errorf("TotalSize = %d, want 400", body.Data.TotalSize)
|
||||
}
|
||||
if body.Data.ObjectCount != 4 {
|
||||
t.Errorf("ObjectCount = %d, want 4", body.Data.ObjectCount)
|
||||
}
|
||||
if body.Data.BucketCount != 2 {
|
||||
t.Errorf("BucketCount = %d, want 2", body.Data.BucketCount)
|
||||
}
|
||||
if len(body.Data.UsageByBucket) != 2 {
|
||||
t.Fatalf("UsageByBucket len = %d, want 2", len(body.Data.UsageByBucket))
|
||||
}
|
||||
// Percentages: 300/400 = 75, 100/400 = 25.
|
||||
for _, u := range body.Data.UsageByBucket {
|
||||
if u.BucketName == "alpha" && u.Percentage != 75 {
|
||||
t.Errorf("alpha pct = %v, want 75", u.Percentage)
|
||||
}
|
||||
if u.BucketName == "beta" && u.Percentage != 25 {
|
||||
t.Errorf("beta pct = %v, want 25", u.Percentage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_GetDashboardMetrics_SkipsInaccessibleBuckets(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return []models.ListBucketsResponseItem{
|
||||
{ID: "good", GlobalAliases: []string{"good"}},
|
||||
{ID: "bad", GlobalAliases: []string{"bad"}},
|
||||
}, nil
|
||||
}
|
||||
admin.GetBucketInfoFn = func(_ context.Context, id string) (*models.GarageBucketInfo, error) {
|
||||
if id == "bad" {
|
||||
return nil, errors.New("access denied")
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "good", Bytes: 10, Objects: 1}, nil
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.DashboardMetrics `json:"data"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if body.Data.BucketCount != 2 {
|
||||
t.Errorf("BucketCount = %d, want 2 (count includes inaccessible)", body.Data.BucketCount)
|
||||
}
|
||||
if len(body.Data.UsageByBucket) != 1 {
|
||||
t.Errorf("UsageByBucket = %d, want 1 (inaccessible skipped)", len(body.Data.UsageByBucket))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_GetDashboardMetrics_ListBucketsErrorReturns500(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return nil, errors.New("upstream")
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil)
|
||||
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,103 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafeContentType_RewritesExecutableTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"text/html", "application/octet-stream"},
|
||||
{"text/html; charset=utf-8", "application/octet-stream"},
|
||||
{"TEXT/HTML", "application/octet-stream"},
|
||||
{" text/html ", "application/octet-stream"},
|
||||
{"application/xhtml+xml", "application/octet-stream"},
|
||||
{"image/svg+xml", "application/octet-stream"},
|
||||
{"application/xml", "application/octet-stream"},
|
||||
{"text/xml", "application/octet-stream"},
|
||||
{"application/javascript", "application/octet-stream"},
|
||||
{"text/javascript", "application/octet-stream"},
|
||||
// Safe types pass through unchanged (including parameters).
|
||||
{"image/png", "image/png"},
|
||||
{"application/octet-stream", "application/octet-stream"},
|
||||
{"text/plain; charset=utf-8", "text/plain; charset=utf-8"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := safeContentType(tc.in); got != tc.want {
|
||||
t.Errorf("safeContentType(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentDispositionHeader_StripsPathAndEscapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
disp, key string
|
||||
mustContain []string
|
||||
mustNotContain []string
|
||||
}{
|
||||
{
|
||||
name: "simple filename",
|
||||
disp: "inline", key: "photo.png",
|
||||
mustContain: []string{
|
||||
`inline; filename="photo.png"; filename*=UTF-8''photo.png`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path components stripped",
|
||||
disp: "attachment", key: "a/b/c/file.txt",
|
||||
mustContain: []string{`filename="file.txt"`, `filename*=UTF-8''file.txt`},
|
||||
mustNotContain: []string{`a/b/c`},
|
||||
},
|
||||
{
|
||||
name: "quote and backslash replaced in ASCII fallback",
|
||||
disp: "inline", key: `"evil\name".txt`,
|
||||
mustContain: []string{`filename="_evil_name_.txt"`},
|
||||
mustNotContain: []string{`"evil`, `\name`},
|
||||
},
|
||||
{
|
||||
name: "control character replaced",
|
||||
disp: "inline", key: "line\nbreak.txt",
|
||||
mustContain: []string{`filename="line_break.txt"`},
|
||||
},
|
||||
{
|
||||
name: "non-ASCII preserved in filename* only",
|
||||
disp: "inline", key: "漢字.txt",
|
||||
mustContain: []string{
|
||||
`filename*=UTF-8''`,
|
||||
// fallback replaced every non-ASCII rune with _ (2 runes + .txt).
|
||||
`filename="__.txt"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty key falls back to download",
|
||||
disp: "inline", key: "",
|
||||
mustContain: []string{`filename="download"`, `filename*=UTF-8''download`},
|
||||
},
|
||||
{
|
||||
name: "key of only slashes falls back to download",
|
||||
disp: "inline", key: "/",
|
||||
mustContain: []string{`filename="download"`, `filename*=UTF-8''`},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := contentDispositionHeader(tc.disp, tc.key)
|
||||
for _, sub := range tc.mustContain {
|
||||
if !strings.Contains(got, sub) {
|
||||
t.Errorf("got %q\nmust contain %q", got, sub)
|
||||
}
|
||||
}
|
||||
for _, sub := range tc.mustNotContain {
|
||||
if strings.Contains(got, sub) {
|
||||
t.Errorf("got %q\nmust NOT contain %q", got, sub)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newObjectsTestApp(t *testing.T) (*fiber.App, *mocks.S3Mock) {
|
||||
t.Helper()
|
||||
s3 := &mocks.S3Mock{}
|
||||
h := NewObjectHandler(s3)
|
||||
app := fiber.New()
|
||||
app.Get("/buckets/:bucket/objects", h.ListObjects)
|
||||
app.Post("/buckets/:bucket/objects", h.UploadObject)
|
||||
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
|
||||
// c.Locals("objectKey") but fall back to c.Params("key"), so :key works.
|
||||
app.Get("/buckets/:bucket/objects/:key", h.GetObject)
|
||||
app.Get("/buckets/:bucket/objects/:key/metadata", h.GetObjectMetadata)
|
||||
app.Get("/buckets/:bucket/objects/:key/presigned", h.GetPresignedURL)
|
||||
app.Delete("/buckets/:bucket/objects/:key", h.DeleteObject)
|
||||
return app, s3
|
||||
}
|
||||
|
||||
// --- ListObjects ---
|
||||
|
||||
func TestListObjects_Success(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ListObjectsFn = func(_ context.Context, bucket, prefix string, max int, tok string) (*models.ObjectListResponse, error) {
|
||||
if bucket != "b1" || prefix != "p/" || max != 50 || tok != "T" {
|
||||
t.Errorf("args = (%q, %q, %d, %q)", bucket, prefix, max, tok)
|
||||
}
|
||||
return &models.ObjectListResponse{
|
||||
Bucket: bucket, Count: 1,
|
||||
Objects: []models.ObjectInfo{{Key: "k1", Size: 1}},
|
||||
}, nil
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?prefix=p/&max_keys=50&continuation_token=T", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.ObjectListResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data.Count != 1 {
|
||||
t.Errorf("count = %d", body.Data.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObjects_DefaultMaxKeys(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ListObjectsFn = func(_ context.Context, _, _ string, max int, _ string) (*models.ObjectListResponse, error) {
|
||||
if max != 100 {
|
||||
t.Errorf("max = %d, want default 100", max)
|
||||
}
|
||||
return &models.ObjectListResponse{}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObjects_InvalidMaxKeys400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
cases := []string{"0", "-1", "abc"}
|
||||
for _, mk := range cases {
|
||||
t.Run(mk, func(t *testing.T) {
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?max_keys="+mk, nil))
|
||||
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 TestListObjects_ServiceError500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ListObjectsFn = func(_ context.Context, _, _ string, _ int, _ string) (*models.ObjectListResponse, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetObjectMetadata ---
|
||||
|
||||
func TestGetObjectMetadata_Success(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectMetadataFn = func(_ context.Context, b, k string) (*models.ObjectInfo, error) {
|
||||
return &models.ObjectInfo{Key: k, Size: 42, ContentType: "image/png"}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/metadata", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.ObjectInfo `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data.Size != 42 {
|
||||
t.Errorf("size = %d", body.Data.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectMetadata_NotFound404(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/metadata", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeleteObject ---
|
||||
|
||||
func TestDeleteObject_Success(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
|
||||
s3.DeleteObjectFn = func(_ context.Context, b, k string) error {
|
||||
if b != "b1" || k != "k1" {
|
||||
t.Errorf("args = (%q, %q)", b, k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteObject_NotExists404(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil }
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/nope", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteObject_ExistsCheckError500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, errors.New("boom") }
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteObject_DeleteError500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
|
||||
s3.DeleteObjectFn = func(_ context.Context, _, _ string) error { return errors.New("boom") }
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetPresignedURL ---
|
||||
|
||||
func TestGetPresignedURL_DefaultExpiration(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
|
||||
s3.GetPresignedURLFn = func(_ context.Context, b, k string, exp time.Duration) (string, error) {
|
||||
if exp != 3600*time.Second {
|
||||
t.Errorf("exp = %v, want 1h", exp)
|
||||
}
|
||||
return "https://example/signed", nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.PresignedURLResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data.URL != "https://example/signed" || body.Data.ExpiresIn != 3600 {
|
||||
t.Errorf("body = %+v", body.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPresignedURL_CustomExpirationWithinRange(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
|
||||
s3.GetPresignedURLFn = func(_ context.Context, _, _ string, exp time.Duration) (string, error) {
|
||||
if exp != 60*time.Second {
|
||||
t.Errorf("exp = %v, want 60s", exp)
|
||||
}
|
||||
return "u", nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in=60", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPresignedURL_InvalidExpiration400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
cases := []string{"0", "-1", "604801", "abc"}
|
||||
for _, val := range cases {
|
||||
t.Run(val, func(t *testing.T) {
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in="+val, nil))
|
||||
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 TestGetPresignedURL_ObjectMissing404(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil }
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/presigned", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetObject ---
|
||||
|
||||
func TestGetObject_Success_StreamsBodyAndHeaders(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
content := []byte("hello world")
|
||||
s3.GetObjectFn = func(_ context.Context, b, k string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
return io.NopCloser(bytes.NewReader(content)), &models.ObjectInfo{
|
||||
Key: k, Size: int64(len(content)), ETag: `"etag-1"`,
|
||||
ContentType: "image/png", LastModified: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Type"); got != "image/png" {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Errorf("X-Content-Type-Options = %q", got)
|
||||
}
|
||||
if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="k1"`) {
|
||||
t.Errorf("Content-Disposition = %q", resp.Header.Get("Content-Disposition"))
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !bytes.Equal(body, content) {
|
||||
t.Errorf("body = %q, want %q", body, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObject_RewritesExecutableContentType(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
return io.NopCloser(strings.NewReader("<script>alert(1)</script>")),
|
||||
&models.ObjectInfo{Key: "evil.html", Size: 25, ContentType: "text/html"},
|
||||
nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/evil.html", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if got := resp.Header.Get("Content-Type"); got != "application/octet-stream" {
|
||||
t.Errorf("Content-Type = %q, want application/octet-stream", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObject_DownloadQuerySetsAttachment(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
return io.NopCloser(strings.NewReader("x")), &models.ObjectInfo{Key: "file.txt", Size: 1}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/file.txt?download=true", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if !strings.HasPrefix(resp.Header.Get("Content-Disposition"), "attachment") {
|
||||
t.Errorf("Content-Disposition = %q, want attachment", resp.Header.Get("Content-Disposition"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObject_ServiceErrorReturns404(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
return nil, nil, errors.New("not found")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// buildMultipart builds a multipart body with a single file field plus
|
||||
// optional additional form fields. Returns body bytes and the Content-Type
|
||||
// header value (which includes the boundary).
|
||||
func buildMultipart(t *testing.T, fields map[string]string, files map[string]struct {
|
||||
Filename string
|
||||
Content []byte
|
||||
ContentType string
|
||||
}) ([]byte, string) {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(buf)
|
||||
for k, v := range fields {
|
||||
if err := w.WriteField(k, v); err != nil {
|
||||
t.Fatalf("WriteField: %v", err)
|
||||
}
|
||||
}
|
||||
for name, f := range files {
|
||||
h := make(map[string][]string)
|
||||
h["Content-Disposition"] = []string{
|
||||
`form-data; name="` + name + `"; filename="` + f.Filename + `"`,
|
||||
}
|
||||
ct := f.ContentType
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
h["Content-Type"] = []string{ct}
|
||||
part, err := w.CreatePart(h)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePart: %v", err)
|
||||
}
|
||||
if _, err := part.Write(f.Content); err != nil {
|
||||
t.Fatalf("part.Write: %v", err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("writer.Close: %v", err)
|
||||
}
|
||||
return buf.Bytes(), w.FormDataContentType()
|
||||
}
|
||||
|
||||
func TestUploadObject_Success(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.UploadObjectFn = func(_ context.Context, bucket, key string, body io.Reader, ct string) (*models.ObjectUploadResponse, error) {
|
||||
if bucket != "b1" || key != "myfile.bin" {
|
||||
t.Errorf("args = (%q, %q)", bucket, key)
|
||||
}
|
||||
if ct != "application/octet-stream" {
|
||||
t.Errorf("contentType = %q", ct)
|
||||
}
|
||||
b, _ := io.ReadAll(body)
|
||||
if string(b) != "payload" {
|
||||
t.Errorf("body = %q, want 'payload'", b)
|
||||
}
|
||||
return &models.ObjectUploadResponse{Bucket: bucket, Key: key, Size: int64(len(b))}, nil
|
||||
}
|
||||
body, ct := buildMultipart(t, nil, map[string]struct {
|
||||
Filename string
|
||||
Content []byte
|
||||
ContentType string
|
||||
}{
|
||||
"file": {Filename: "myfile.bin", Content: []byte("payload")},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadObject_ExplicitKeyOverridesFilename(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.UploadObjectFn = func(_ context.Context, _, key string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) {
|
||||
if key != "custom/key.txt" {
|
||||
t.Errorf("key = %q, want custom/key.txt", key)
|
||||
}
|
||||
return &models.ObjectUploadResponse{Key: key}, nil
|
||||
}
|
||||
body, ct := buildMultipart(t, map[string]string{"key": "custom/key.txt"}, map[string]struct {
|
||||
Filename string
|
||||
Content []byte
|
||||
ContentType string
|
||||
}{
|
||||
"file": {Filename: "whatever.txt", Content: []byte("x")},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
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", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadObject_MissingFileReturns400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
body, ct := buildMultipart(t, map[string]string{"key": "k"}, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
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 TestUploadObject_ServiceError500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.UploadObjectFn = func(_ context.Context, _, _ string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
body, ct := buildMultipart(t, nil, map[string]struct {
|
||||
Filename string
|
||||
Content []byte
|
||||
ContentType string
|
||||
}{"file": {Filename: "f.bin", Content: []byte("x")}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_Success(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.DeleteMultipleObjectsFn = func(_ context.Context, bucket string, keys []string) error {
|
||||
if bucket != "b1" || len(keys) != 3 {
|
||||
t.Errorf("args = (%q, %v)", bucket, keys)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b", "c"}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Data models.ObjectDeleteMultipleResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &out)
|
||||
if out.Data.Deleted != 3 {
|
||||
t.Errorf("Deleted = %d, want 3", out.Data.Deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_EmptyKeys400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
body, _ := json.Marshal(map[string]any{"keys": []string{}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_MalformedJSON400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", strings.NewReader("{not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_ServiceError500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, _ []string) error { return errors.New("boom") }
|
||||
body, _ := json.Marshal(map[string]any{"keys": []string{"a"}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// buildMultipartMulti builds a body with N "files" parts.
|
||||
func buildMultipartMulti(t *testing.T, files []struct {
|
||||
Filename, ContentType string
|
||||
Content []byte
|
||||
}) ([]byte, string) {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(buf)
|
||||
for _, f := range files {
|
||||
h := map[string][]string{
|
||||
"Content-Disposition": {`form-data; name="files"; filename="` + f.Filename + `"`},
|
||||
"Content-Type": {f.ContentType},
|
||||
}
|
||||
part, err := w.CreatePart(h)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePart: %v", err)
|
||||
}
|
||||
_, _ = part.Write(f.Content)
|
||||
}
|
||||
_ = w.Close()
|
||||
return buf.Bytes(), w.FormDataContentType()
|
||||
}
|
||||
|
||||
func TestUploadMultiple_AllSuccess201(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.UploadMultipleObjectsFn = func(_ context.Context, bucket string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}) []services.UploadResult {
|
||||
if bucket != "b1" || len(files) != 2 {
|
||||
t.Errorf("got bucket=%q files=%d", bucket, len(files))
|
||||
}
|
||||
out := make([]services.UploadResult, len(files))
|
||||
for i, f := range files {
|
||||
out[i] = services.UploadResult{Key: f.Key, Success: true, ContentType: f.ContentType, Size: 1}
|
||||
}
|
||||
return out
|
||||
}
|
||||
body, ct := buildMultipartMulti(t, []struct {
|
||||
Filename, ContentType string
|
||||
Content []byte
|
||||
}{
|
||||
{"a.txt", "text/plain", []byte("a")},
|
||||
{"b.txt", "text/plain", []byte("b")},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw)
|
||||
}
|
||||
var out struct {
|
||||
Data models.ObjectUploadMultipleResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &out)
|
||||
if out.Data.SuccessCount != 2 || out.Data.FailureCount != 0 {
|
||||
t.Errorf("counts = (%d, %d)", out.Data.SuccessCount, out.Data.FailureCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMultiple_PartialReturns207(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}) []services.UploadResult {
|
||||
return []services.UploadResult{
|
||||
{Key: files[0].Key, Success: true, Size: 1, ContentType: files[0].ContentType},
|
||||
{Key: files[1].Key, Success: false, Error: errors.New("upload failed"), ContentType: files[1].ContentType},
|
||||
}
|
||||
}
|
||||
body, ct := buildMultipartMulti(t, []struct {
|
||||
Filename, ContentType string
|
||||
Content []byte
|
||||
}{
|
||||
{"a.txt", "text/plain", []byte("a")},
|
||||
{"b.txt", "text/plain", []byte("b")},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMultiStatus {
|
||||
t.Fatalf("status = %d, want 207", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMultiple_AllFailReturns500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.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: false, Error: errors.New("boom")}
|
||||
}
|
||||
return out
|
||||
}
|
||||
body, ct := buildMultipartMulti(t, []struct {
|
||||
Filename, ContentType string
|
||||
Content []byte
|
||||
}{{"a.txt", "text/plain", []byte("a")}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMultiple_NoFiles400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
body, ct := buildMultipartMulti(t, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
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 TestUploadMultiple_DefaultsContentType(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}) []services.UploadResult {
|
||||
if files[0].ContentType != "application/octet-stream" {
|
||||
t.Errorf("ContentType = %q, want default application/octet-stream", files[0].ContentType)
|
||||
}
|
||||
return []services.UploadResult{{Key: files[0].Key, Success: true}}
|
||||
}
|
||||
// Write a part with an empty Content-Type header explicitly.
|
||||
buf := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(buf)
|
||||
part, err := w.CreatePart(map[string][]string{
|
||||
"Content-Disposition": {`form-data; name="files"; filename="a.txt"`},
|
||||
"Content-Type": {""},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePart: %v", err)
|
||||
}
|
||||
_, _ = part.Write([]byte("x"))
|
||||
_ = w.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(buf.Bytes()))
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newUsersTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
|
||||
t.Helper()
|
||||
admin := &mocks.AdminMock{}
|
||||
h := NewUserHandler(admin)
|
||||
app := fiber.New()
|
||||
app.Get("/users", h.ListUsers)
|
||||
app.Post("/users", h.CreateUser)
|
||||
app.Get("/users/:access_key", h.GetUser)
|
||||
app.Get("/users/:access_key/secret", h.GetUserSecretKey)
|
||||
app.Delete("/users/:access_key", h.DeleteUser)
|
||||
app.Patch("/users/:access_key", h.UpdateUserPermissions)
|
||||
return app, admin
|
||||
}
|
||||
|
||||
// --- ListUsers ---
|
||||
|
||||
func TestListUsers_MapsAndSkipsFailed(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.ListKeysFn = func(_ context.Context) ([]models.ListKeysResponseItem, error) {
|
||||
return []models.ListKeysResponseItem{
|
||||
{ID: "AKIA-1", Name: "one"},
|
||||
{ID: "AKIA-2", Name: "bad-detail"},
|
||||
{ID: "AKIA-3", Name: "three"},
|
||||
}, nil
|
||||
}
|
||||
admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
if showSecret {
|
||||
t.Error("ListUsers must not request secret")
|
||||
}
|
||||
if id == "AKIA-2" {
|
||||
return nil, errors.New("forbidden")
|
||||
}
|
||||
return &models.GarageKeyInfo{AccessKeyID: id, Name: id, Expired: false}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.UserListResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data.Count != 2 {
|
||||
t.Errorf("count = %d, want 2 (failed detail skipped)", body.Data.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsers_ListError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.ListKeysFn = func(_ context.Context) ([]models.ListKeysResponseItem, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- CreateUser ---
|
||||
|
||||
func TestCreateUser_Success201(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.CreateKeyFn = func(_ context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
if req.Name == nil || *req.Name != "alice" {
|
||||
t.Errorf("Name = %v", req.Name)
|
||||
}
|
||||
sk := "secret-xyz"
|
||||
return &models.GarageKeyInfo{AccessKeyID: "AKIA-1", Name: "alice", SecretAccessKey: &sk}, nil
|
||||
}
|
||||
body, _ := json.Marshal(models.CreateUserRequest{Name: "alice"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
||||
}
|
||||
var decoded struct {
|
||||
Data models.UserInfo `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &decoded)
|
||||
if decoded.Data.SecretKey == nil || *decoded.Data.SecretKey != "secret-xyz" {
|
||||
t.Errorf("SecretKey = %v, want secret-xyz", decoded.Data.SecretKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_MalformedJSON400(t *testing.T) {
|
||||
app, _ := newUsersTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader("{not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_AdminError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.CreateKeyFn = func(_ context.Context, _ models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
body, _ := json.Marshal(models.CreateUserRequest{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetUser ---
|
||||
|
||||
func TestGetUser_Success(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
if showSecret {
|
||||
t.Error("GetUser must not request secret")
|
||||
}
|
||||
return &models.GarageKeyInfo{AccessKeyID: id, Name: "alice"}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_ServiceError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.GetKeyInfoFn = func(_ context.Context, _ string, _ bool) (*models.GarageKeyInfo, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetUserSecretKey ---
|
||||
|
||||
func TestGetUserSecretKey_Success(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
if !showSecret {
|
||||
t.Error("GetUserSecretKey must request secret")
|
||||
}
|
||||
sk := "s3cr3t"
|
||||
return &models.GarageKeyInfo{AccessKeyID: id, SecretAccessKey: &sk}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1/secret", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data["secretKey"] != "s3cr3t" {
|
||||
t.Errorf("secretKey = %q", body.Data["secretKey"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeleteUser ---
|
||||
|
||||
func TestDeleteUser_Success(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.DeleteKeyFn = func(_ context.Context, id string) error {
|
||||
if id != "AKIA-1" {
|
||||
t.Errorf("id = %q", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/AKIA-1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser_ServiceError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.DeleteKeyFn = func(_ context.Context, _ string) error { return errors.New("boom") }
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/AKIA-1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- UpdateUserPermissions ---
|
||||
|
||||
func TestUpdateUser_StatusInactiveSetsPastExpiration(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
if req.NeverExpires {
|
||||
t.Error("NeverExpires should be false when deactivating")
|
||||
}
|
||||
if req.Expiration == nil || !req.Expiration.Before(time.Now()) {
|
||||
t.Errorf("Expiration = %v, want past time", req.Expiration)
|
||||
}
|
||||
return &models.GarageKeyInfo{}, nil
|
||||
}
|
||||
status := "inactive"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Status: &status})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_StatusActiveSetsNeverExpires(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
if !req.NeverExpires {
|
||||
t.Error("NeverExpires should be true when activating")
|
||||
}
|
||||
return &models.GarageKeyInfo{}, nil
|
||||
}
|
||||
status := "active"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Status: &status})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_ExplicitExpiration(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
wantTime, _ := time.Parse(time.RFC3339, "2030-01-02T03:04:05Z")
|
||||
admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
if req.Expiration == nil || !req.Expiration.Equal(wantTime) {
|
||||
t.Errorf("Expiration = %v, want %v", req.Expiration, wantTime)
|
||||
}
|
||||
if req.NeverExpires {
|
||||
t.Error("NeverExpires should be false with explicit expiration")
|
||||
}
|
||||
return &models.GarageKeyInfo{}, nil
|
||||
}
|
||||
exp := "2030-01-02T03:04:05Z"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Expiration: &exp})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_BadExpirationFormat400(t *testing.T) {
|
||||
app, _ := newUsersTestApp(t)
|
||||
exp := "not-a-date"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Expiration: &exp})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_MalformedJSON400(t *testing.T) {
|
||||
app, _ := newUsersTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", strings.NewReader("{not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_AdminError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.UpdateKeyFn = func(_ context.Context, _ string, _ models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
status := "active"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Status: &status})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// newLoggingTestApp installs RequestID + Logging middleware with a provided
|
||||
// zerolog.Logger writing to buf so tests can assert on the JSON output.
|
||||
func newLoggingTestApp(t *testing.T, buf *bytes.Buffer, handler fiber.Handler) *fiber.App {
|
||||
t.Helper()
|
||||
base := zerolog.New(buf)
|
||||
app := fiber.New()
|
||||
app.Use(RequestID())
|
||||
app.Use(Logging(base))
|
||||
app.Get("/ping", handler)
|
||||
app.Get("/health", handler)
|
||||
return app
|
||||
}
|
||||
|
||||
func parseLines(t *testing.T, buf *bytes.Buffer) []map[string]any {
|
||||
t.Helper()
|
||||
out := []map[string]any{}
|
||||
for _, line := range strings.Split(buf.String(), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||
t.Fatalf("not JSON: %v — %s", err, line)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestLogging_InjectsLoggerIntoContext(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error {
|
||||
logpkg.FromCtx(c.Context()).Info().Str("stage", "handler").Msg("handled")
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
lines := parseLines(t, &buf)
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("expected >=2 log lines (handler + access), got %d: %q", len(lines), buf.String())
|
||||
}
|
||||
|
||||
h := lines[0]
|
||||
if h["stage"] != "handler" {
|
||||
t.Errorf("handler line stage = %v", h["stage"])
|
||||
}
|
||||
if _, ok := h["request_id"].(string); !ok || h["request_id"] == "" {
|
||||
t.Errorf("handler line missing request_id: %v", h)
|
||||
}
|
||||
if h["method"] != "GET" || h["path"] != "/ping" {
|
||||
t.Errorf("handler line missing method/path: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogging_EmitsAccessLogLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
lines := parseLines(t, &buf)
|
||||
var access map[string]any
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if _, ok := lines[i]["status"]; ok {
|
||||
access = lines[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if access == nil {
|
||||
t.Fatalf("no access-log line found: %s", buf.String())
|
||||
}
|
||||
|
||||
if access["method"] != "GET" || access["path"] != "/ping" {
|
||||
t.Errorf("access line method/path wrong: %v", access)
|
||||
}
|
||||
if got, _ := access["status"].(float64); got != 200 {
|
||||
t.Errorf("access line status = %v, want 200", access["status"])
|
||||
}
|
||||
if _, ok := access["duration_ms"].(float64); !ok {
|
||||
t.Errorf("access line missing duration_ms: %v", access)
|
||||
}
|
||||
if access["level"] != "info" {
|
||||
t.Errorf("200 should log at info; got level %v", access["level"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogging_SkipsHealthEndpoint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
for _, line := range parseLines(t, &buf) {
|
||||
if _, hasStatus := line["status"]; hasStatus {
|
||||
if line["path"] == "/health" {
|
||||
t.Errorf("/health should not emit an access log line: %v", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogging_LevelByStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
wantLevel string
|
||||
}{
|
||||
{200, "info"},
|
||||
{404, "warn"},
|
||||
{500, "error"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.wantLevel, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error {
|
||||
return c.Status(tc.status).SendString("x")
|
||||
})
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
var access map[string]any
|
||||
for _, line := range parseLines(t, &buf) {
|
||||
if _, ok := line["status"]; ok {
|
||||
access = line
|
||||
}
|
||||
}
|
||||
if access == nil {
|
||||
t.Fatalf("no access line")
|
||||
}
|
||||
if access["level"] != tc.wantLevel {
|
||||
t.Errorf("status %d → level %v, want %v", tc.status, access["level"], tc.wantLevel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RequestIDHeader is the HTTP header used to read an incoming request ID
|
||||
// (for cross-service correlation) and to echo the request ID in the response.
|
||||
const RequestIDHeader = "X-Request-ID"
|
||||
|
||||
// RequestIDLocalsKey is the fiber.Ctx.Locals key carrying the request ID.
|
||||
const RequestIDLocalsKey = "request_id"
|
||||
|
||||
// RequestID returns middleware that assigns a request ID to every request.
|
||||
// If the client sends X-Request-ID, that value is used; otherwise a new
|
||||
// UUIDv4 is generated. The ID is stored on c.Locals and echoed in the
|
||||
// response header so clients and downstream services can correlate logs.
|
||||
func RequestID() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
id := c.Get(RequestIDHeader)
|
||||
if id == "" {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
c.Locals(RequestIDLocalsKey, id)
|
||||
c.Set(RequestIDHeader, id)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func newTestApp(t *testing.T, handler fiber.Handler, mw ...fiber.Handler) *fiber.App {
|
||||
t.Helper()
|
||||
app := fiber.New()
|
||||
for _, m := range mw {
|
||||
app.Use(m)
|
||||
}
|
||||
app.Get("/ping", handler)
|
||||
return app
|
||||
}
|
||||
|
||||
func TestRequestID_GeneratesWhenAbsent(t *testing.T) {
|
||||
var seen string
|
||||
app := newTestApp(t, func(c fiber.Ctx) error {
|
||||
seen, _ = c.Locals("request_id").(string)
|
||||
return c.SendString("ok")
|
||||
}, RequestID())
|
||||
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
if seen == "" {
|
||||
t.Fatal("request_id not set on c.Locals")
|
||||
}
|
||||
if _, err := uuid.Parse(seen); err != nil {
|
||||
t.Errorf("request_id %q is not a valid UUID: %v", seen, err)
|
||||
}
|
||||
if got := resp.Header.Get("X-Request-ID"); got != seen {
|
||||
t.Errorf("X-Request-ID response header = %q, want %q", got, seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestID_HonorsIncomingHeader(t *testing.T) {
|
||||
var seen string
|
||||
app := newTestApp(t, func(c fiber.Ctx) error {
|
||||
seen, _ = c.Locals("request_id").(string)
|
||||
return c.SendString("ok")
|
||||
}, RequestID())
|
||||
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
req.Header.Set("X-Request-ID", "incoming-abc-123")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
if seen != "incoming-abc-123" {
|
||||
t.Errorf("request_id = %q, want incoming-abc-123", seen)
|
||||
}
|
||||
if got := resp.Header.Get("X-Request-ID"); got != "incoming-abc-123" {
|
||||
t.Errorf("X-Request-ID response header = %q, want incoming-abc-123", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// newAdminWithServer creates a GarageAdminService pointed at a test server
|
||||
// and returns it with a cleanup hook.
|
||||
func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageAdminService {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
cfg := &config.GarageConfig{
|
||||
AdminEndpoint: srv.URL,
|
||||
AdminToken: "test-token",
|
||||
}
|
||||
return NewGarageAdminService(cfg, "info")
|
||||
}
|
||||
|
||||
// ctxWithBufferLogger attaches a zerolog.Logger writing to buf onto ctx.
|
||||
func ctxWithBufferLogger(buf *bytes.Buffer) context.Context {
|
||||
l := zerolog.New(buf)
|
||||
return logpkg.IntoCtx(context.Background(), l)
|
||||
}
|
||||
|
||||
func parseJSONLines(t *testing.T, s string) []map[string]any {
|
||||
t.Helper()
|
||||
out := []map[string]any{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||
t.Fatalf("not JSON: %v — %s", err, line)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCreateBucket_SuccessLogsInfoWithFields(t *testing.T) {
|
||||
admin := newAdminWithServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(models.GarageBucketInfo{ID: "bkt-123"})
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
ctx := ctxWithBufferLogger(&buf)
|
||||
|
||||
alias := "my-bucket"
|
||||
if _, err := admin.CreateBucket(ctx, models.CreateBucketAdminRequest{GlobalAlias: &alias}); err != nil {
|
||||
t.Fatalf("CreateBucket: %v", err)
|
||||
}
|
||||
|
||||
lines := parseJSONLines(t, buf.String())
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("expected >=2 lines (start + success), got %d: %s", len(lines), buf.String())
|
||||
}
|
||||
|
||||
final := lines[len(lines)-1]
|
||||
if final["component"] != "admin" {
|
||||
t.Errorf("component = %v, want admin", final["component"])
|
||||
}
|
||||
if final["operation"] != "create_bucket" {
|
||||
t.Errorf("operation = %v, want create_bucket", final["operation"])
|
||||
}
|
||||
if final["outcome"] != "success" {
|
||||
t.Errorf("outcome = %v, want success", final["outcome"])
|
||||
}
|
||||
if final["level"] != "info" {
|
||||
t.Errorf("level = %v, want info", final["level"])
|
||||
}
|
||||
if _, ok := final["duration_ms"].(float64); !ok {
|
||||
t.Errorf("missing duration_ms: %v", final)
|
||||
}
|
||||
if final["bucket_id"] != "bkt-123" {
|
||||
t.Errorf("bucket_id = %v, want bkt-123", final["bucket_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBucket_FailureLogsError(t *testing.T) {
|
||||
admin := newAdminWithServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"error":"already exists"}`))
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
ctx := ctxWithBufferLogger(&buf)
|
||||
|
||||
alias := "duplicate"
|
||||
_, err := admin.CreateBucket(ctx, models.CreateBucketAdminRequest{GlobalAlias: &alias})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
lines := parseJSONLines(t, buf.String())
|
||||
var failure map[string]any
|
||||
for _, line := range lines {
|
||||
if line["level"] == "error" {
|
||||
failure = line
|
||||
break
|
||||
}
|
||||
}
|
||||
if failure == nil {
|
||||
t.Fatalf("no error-level log line: %s", buf.String())
|
||||
}
|
||||
|
||||
if failure["outcome"] != "failure" {
|
||||
t.Errorf("outcome = %v, want failure", failure["outcome"])
|
||||
}
|
||||
if failure["operation"] != "create_bucket" {
|
||||
t.Errorf("operation = %v, want create_bucket", failure["operation"])
|
||||
}
|
||||
if _, ok := failure["error"].(string); !ok {
|
||||
t.Errorf("missing error field: %v", failure)
|
||||
}
|
||||
if _, ok := failure["duration_ms"].(float64); !ok {
|
||||
t.Errorf("missing duration_ms: %v", failure)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
)
|
||||
|
||||
// newAdminTestServer wires an httptest.Server (with the supplied handler) to a
|
||||
// fresh *GarageAdminService configured with a known bearer token.
|
||||
func newAdminTestServer(t *testing.T, handler http.Handler) (*GarageAdminService, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
svc := NewGarageAdminService(&config.GarageConfig{
|
||||
AdminEndpoint: srv.URL,
|
||||
AdminToken: "test-token-xyz",
|
||||
}, "")
|
||||
return svc, srv
|
||||
}
|
||||
|
||||
// jsonHandler returns an http.Handler that writes the supplied status code and
|
||||
// JSON-encoded body.
|
||||
func jsonHandler(t *testing.T, status int, body any) http.Handler {
|
||||
t.Helper()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if body == nil {
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
t.Errorf("server: encode response: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type recordedRequest struct {
|
||||
method string
|
||||
path string
|
||||
rawURL string
|
||||
auth string
|
||||
body []byte
|
||||
}
|
||||
|
||||
func recordingHandler(t *testing.T, status int, respBody any) (http.Handler, *recordedRequest) {
|
||||
t.Helper()
|
||||
rec := &recordedRequest{}
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rec.method = r.Method
|
||||
rec.path = r.URL.Path
|
||||
rec.rawURL = r.URL.RequestURI()
|
||||
rec.auth = r.Header.Get("Authorization")
|
||||
if r.Body != nil {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("server: read body: %v", err)
|
||||
}
|
||||
rec.body = b
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if respBody != nil {
|
||||
_ = json.NewEncoder(w).Encode(respBody)
|
||||
}
|
||||
})
|
||||
return h, rec
|
||||
}
|
||||
|
||||
func TestHealthCheck_Success(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, nil)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
if err := svc.HealthCheck(context.Background()); err != nil {
|
||||
t.Fatalf("HealthCheck: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", rec.method)
|
||||
}
|
||||
if rec.path != "/health" {
|
||||
t.Errorf("path = %q, want /health", rec.path)
|
||||
}
|
||||
if rec.auth != "Bearer test-token-xyz" {
|
||||
t.Errorf("Authorization = %q, want Bearer test-token-xyz", rec.auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheck_Non2xxReturnsError(t *testing.T) {
|
||||
h := jsonHandler(t, http.StatusServiceUnavailable, map[string]string{"error": "down"})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
err := svc.HealthCheck(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 503, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "503") {
|
||||
t.Errorf("error should mention status code, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListKeys_Success(t *testing.T) {
|
||||
want := []models.ListKeysResponseItem{
|
||||
{ID: "k1", Name: "alice", Expired: false},
|
||||
{ID: "k2", Name: "bob", Expired: true},
|
||||
}
|
||||
h, rec := recordingHandler(t, http.StatusOK, want)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
got, err := svc.ListKeys(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListKeys: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodGet || rec.path != "/v2/ListKeys" {
|
||||
t.Errorf("request = %s %s, want GET /v2/ListKeys", rec.method, rec.path)
|
||||
}
|
||||
if len(got) != 2 || got[0].ID != "k1" || got[1].Name != "bob" {
|
||||
t.Errorf("got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateKey_PostsBodyAndDecodesResponse(t *testing.T) {
|
||||
name := "service-account"
|
||||
want := &models.GarageKeyInfo{
|
||||
AccessKeyID: "GK123",
|
||||
Name: name,
|
||||
}
|
||||
h, rec := recordingHandler(t, http.StatusOK, want)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
req := models.CreateKeyRequest{Name: &name, NeverExpires: true}
|
||||
got, err := svc.CreateKey(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateKey: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodPost || rec.path != "/v2/CreateKey" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
var sent models.CreateKeyRequest
|
||||
if err := json.Unmarshal(rec.body, &sent); err != nil {
|
||||
t.Fatalf("unmarshal sent body: %v (raw=%q)", err, rec.body)
|
||||
}
|
||||
if sent.Name == nil || *sent.Name != name {
|
||||
t.Errorf("sent name = %v, want %q", sent.Name, name)
|
||||
}
|
||||
if !sent.NeverExpires {
|
||||
t.Errorf("NeverExpires should round-trip true")
|
||||
}
|
||||
if got.AccessKeyID != want.AccessKeyID {
|
||||
t.Errorf("AccessKeyID = %q, want %q", got.AccessKeyID, want.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetKeyInfo_PassesShowSecretQueryParam(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
showSecret bool
|
||||
wantInRawURL string
|
||||
}{
|
||||
{"without secret", false, "?id=ABC"},
|
||||
{"with secret", true, "?id=ABC&showSecretKey=true"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, &models.GarageKeyInfo{AccessKeyID: "ABC"})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
_, err := svc.GetKeyInfo(context.Background(), "ABC", tc.showSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("GetKeyInfo: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, tc.wantInRawURL) {
|
||||
t.Errorf("raw URL = %q, want substring %q", rec.rawURL, tc.wantInRawURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateKey_UsesIDInQuery(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, &models.GarageKeyInfo{AccessKeyID: "K"})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
if _, err := svc.UpdateKey(context.Background(), "K", models.UpdateKeyRequest{}); err != nil {
|
||||
t.Fatalf("UpdateKey: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=K") {
|
||||
t.Errorf("raw URL = %q, want substring id=K", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteKey_NoBodyOnSuccess(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, nil)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
if err := svc.DeleteKey(context.Background(), "K"); err != nil {
|
||||
t.Fatalf("DeleteKey: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=K") {
|
||||
t.Errorf("raw URL = %q", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportKey_PostsToImportEndpoint(t *testing.T) {
|
||||
want := &models.GarageKeyInfo{AccessKeyID: "imported"}
|
||||
h, rec := recordingHandler(t, http.StatusOK, want)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
req := models.ImportKeyRequest{
|
||||
AccessKeyID: "imported",
|
||||
SecretAccessKey: "shhh",
|
||||
}
|
||||
got, err := svc.ImportKey(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportKey: %v", err)
|
||||
}
|
||||
if rec.path != "/v2/ImportKey" {
|
||||
t.Errorf("path = %q", rec.path)
|
||||
}
|
||||
if got.AccessKeyID != "imported" {
|
||||
t.Errorf("AccessKeyID = %q, want imported", got.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBuckets_Success(t *testing.T) {
|
||||
want := []models.ListBucketsResponseItem{{ID: "b1"}, {ID: "b2"}}
|
||||
h, rec := recordingHandler(t, http.StatusOK, want)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
got, err := svc.ListBuckets(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListBuckets: %v", err)
|
||||
}
|
||||
if rec.path != "/v2/ListBuckets" {
|
||||
t.Errorf("path = %q", rec.path)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("len = %d, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketInfo_UsesIDQuery(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
if _, err := svc.GetBucketInfo(context.Background(), "B"); err != nil {
|
||||
t.Fatalf("GetBucketInfo: %v", err)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=B") {
|
||||
t.Errorf("raw URL = %q", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketInfoByAlias_UsesGlobalAliasQuery(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B", GlobalAliases: []string{"my-bucket"}})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
got, err := svc.GetBucketInfoByAlias(context.Background(), "my-bucket")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBucketInfoByAlias: %v", err)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "globalAlias=my-bucket") {
|
||||
t.Errorf("raw URL = %q", rec.rawURL)
|
||||
}
|
||||
if got.ID != "B" {
|
||||
t.Errorf("ID = %q, want B", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBucket_PostsBody(t *testing.T) {
|
||||
want := &models.GarageBucketInfo{ID: "new"}
|
||||
h, rec := recordingHandler(t, http.StatusOK, want)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
if _, err := svc.CreateBucket(context.Background(), models.CreateBucketAdminRequest{}); err != nil {
|
||||
t.Fatalf("CreateBucket: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodPost || rec.path != "/v2/CreateBucket" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucket_UsesIDQueryAndPostsBody(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
if _, err := svc.UpdateBucket(context.Background(), "B", models.UpdateBucketRequest{}); err != nil {
|
||||
t.Fatalf("UpdateBucket: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodPost {
|
||||
t.Errorf("method = %q", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=B") {
|
||||
t.Errorf("raw URL = %q", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBucket_PostWithIDQuery(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, nil)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
if err := svc.DeleteBucket(context.Background(), "B"); err != nil {
|
||||
t.Fatalf("DeleteBucket: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodPost {
|
||||
t.Errorf("method = %q", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=B") {
|
||||
t.Errorf("raw URL = %q", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(s *GarageAdminService) error
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "AddBucketAlias",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{})
|
||||
return err
|
||||
},
|
||||
path: "/v2/AddBucketAlias",
|
||||
},
|
||||
{
|
||||
name: "RemoveBucketAlias",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{})
|
||||
return err
|
||||
},
|
||||
path: "/v2/RemoveBucketAlias",
|
||||
},
|
||||
{
|
||||
name: "AllowBucketKey",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.AllowBucketKey(context.Background(), models.BucketKeyPermRequest{})
|
||||
return err
|
||||
},
|
||||
path: "/v2/AllowBucketKey",
|
||||
},
|
||||
{
|
||||
name: "DenyBucketKey",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.DenyBucketKey(context.Background(), models.BucketKeyPermRequest{})
|
||||
return err
|
||||
},
|
||||
path: "/v2/DenyBucketKey",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
if err := tc.fn(svc); err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
if rec.path != tc.path || rec.method != http.MethodPost {
|
||||
t.Errorf("request = %s %s, want POST %s", rec.method, rec.path, tc.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterEndpoints(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(s *GarageAdminService) error
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "GetClusterHealth",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.GetClusterHealth(context.Background())
|
||||
return err
|
||||
},
|
||||
path: "/v2/GetClusterHealth",
|
||||
},
|
||||
{
|
||||
name: "GetClusterStatus",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.GetClusterStatus(context.Background())
|
||||
return err
|
||||
},
|
||||
path: "/v2/GetClusterStatus",
|
||||
},
|
||||
{
|
||||
name: "GetClusterStatistics",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.GetClusterStatistics(context.Background())
|
||||
return err
|
||||
},
|
||||
path: "/v2/GetClusterStatistics",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, struct{}{})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
if err := tc.fn(svc); err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
if rec.method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", rec.method)
|
||||
}
|
||||
if rec.path != tc.path {
|
||||
t.Errorf("path = %q, want %q", rec.path, tc.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeEndpoints_NodeIDInQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(s *GarageAdminService, id string) error
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "GetNodeInfo",
|
||||
fn: func(s *GarageAdminService, id string) error {
|
||||
_, err := s.GetNodeInfo(context.Background(), id)
|
||||
return err
|
||||
},
|
||||
path: "/v2/GetNodeInfo",
|
||||
},
|
||||
{
|
||||
name: "GetNodeStatistics",
|
||||
fn: func(s *GarageAdminService, id string) error {
|
||||
_, err := s.GetNodeStatistics(context.Background(), id)
|
||||
return err
|
||||
},
|
||||
path: "/v2/GetNodeStatistics",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, struct{}{})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
if err := tc.fn(svc, "node-7"); err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
if rec.path != tc.path {
|
||||
t.Errorf("path = %q, want %q", rec.path, tc.path)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "node=node-7") {
|
||||
t.Errorf("raw URL = %q, want substring node=node-7", rec.rawURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetrics_ReturnsRawBodyOn2xx(t *testing.T) {
|
||||
body := "# HELP garage_up Number of running nodes\ngarage_up 3\n"
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, body)
|
||||
})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
got, err := svc.GetMetrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetrics: %v", err)
|
||||
}
|
||||
if got != body {
|
||||
t.Errorf("GetMetrics body mismatch:\n got %q\nwant %q", got, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetrics_Non2xxReturnsErrorWithStatus(t *testing.T) {
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(w, "forbidden")
|
||||
})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
_, err := svc.GetMetrics(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("error should mention 403, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_Non2xxBodyEchoedInError(t *testing.T) {
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, `{"error":"bad request"}`)
|
||||
})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
_, err := svc.ListKeys(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 400, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), "bad request") {
|
||||
t.Errorf("error %v should contain 400 and the response body", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_MalformedJSONReturnsDecodeError(t *testing.T) {
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, "not-json-at-all")
|
||||
})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
_, err := svc.ListKeys(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected decode error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "decode") {
|
||||
t.Errorf("error %v should mention decoding", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_RetriesExhaustOnConnectionRefused(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
addr := listener.Addr().String()
|
||||
if err := listener.Close(); err != nil {
|
||||
t.Fatalf("close listener: %v", err)
|
||||
}
|
||||
|
||||
svc := NewGarageAdminService(&config.GarageConfig{
|
||||
AdminEndpoint: "http://" + addr,
|
||||
AdminToken: "irrelevant",
|
||||
}, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
_, err = svc.ListKeys(ctx)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error after retries exhaust, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "max retries") {
|
||||
t.Errorf("expected max-retries error, got %v", err)
|
||||
}
|
||||
if elapsed < 200*time.Millisecond {
|
||||
t.Errorf("retries returned in %v — backoff loop not engaged", elapsed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package logger
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRedactKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", "***"},
|
||||
{"short", "abc", "***"},
|
||||
{"just-under-threshold", "abcdefghijk", "***"}, // 11 chars
|
||||
{"at-threshold", "abcdefghijkl", "abcd…ijkl"}, // 12 chars
|
||||
{"long", "GK5a9bfcdefghijklzW9q", "GK5a…zW9q"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := RedactKey(tc.in); got != tc.want {
|
||||
t.Errorf("RedactKey(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactToken_AlwaysStars(t *testing.T) {
|
||||
for _, in := range []string{"", "abc", "very-long-secret-token-value"} {
|
||||
if got := RedactToken(in); got != "***" {
|
||||
t.Errorf("RedactToken(%q) = %q, want ***", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user