diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index bd7ab05..20ba4cd 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -8,6 +8,7 @@ import ( "github.com/dhao2001/mygo/internal/api" "github.com/dhao2001/mygo/internal/auth" + "github.com/dhao2001/mygo/internal/repository" ) const userIDKey = "user_id" @@ -68,3 +69,33 @@ func MustGetUserID(c *gin.Context) string { } return userID } + +// AdminRequired returns a Gin middleware that gates access to admin-only +// endpoints. It must be applied AFTER AuthRequired. It fetches the user from +// the repository, and returns 403 if the user is not an admin. Soft-deleted +// users are not found by FindByID and will receive a 401 response. +func AdminRequired(userRepo repository.UserRepository) gin.HandlerFunc { + return func(c *gin.Context) { + userID := GetUserID(c) + if userID == "" { + api.Error(c, http.StatusUnauthorized, "missing user context") + c.Abort() + return + } + + user, err := userRepo.FindByID(c.Request.Context(), userID) + if err != nil { + api.Error(c, http.StatusUnauthorized, "user not found") + c.Abort() + return + } + + if !user.IsAdmin { + api.Error(c, http.StatusForbidden, "admin access required") + c.Abort() + return + } + + c.Next() + } +} diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go index 08da150..5c1835d 100644 --- a/internal/middleware/auth_test.go +++ b/internal/middleware/auth_test.go @@ -1,6 +1,8 @@ package middleware import ( + "context" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -8,8 +10,12 @@ import ( "time" "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" "github.com/dhao2001/mygo/internal/auth" + "github.com/dhao2001/mygo/internal/model" + "github.com/dhao2001/mygo/internal/repository" ) func setupTestRouter(secret []byte) *gin.Engine { @@ -196,3 +202,162 @@ func TestAuthRequiredWrongSecret(t *testing.T) { t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } } + +// --- AdminRequired tests --- + +// errorBody extracts the error.message field from a JSON response body. +type errorBody struct { + Error struct { + Message string `json:"message"` + } `json:"error"` +} + +func setupAdminTestDB(t *testing.T) repository.UserRepository { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.User{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + return repository.NewUserRepository(db) +} + +// injectUserIDMiddleware simulates AuthRequired by injecting a user_id into the context. +func injectUserIDMiddleware(userID string) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set(userIDKey, userID) + c.Next() + } +} + +func TestAdminRequired_AdminPasses(t *testing.T) { + repo := setupAdminTestDB(t) + ctx := context.Background() + + user := &model.User{ + ID: "admin-1", + Username: "admin", + Email: "admin@example.com", + IsAdmin: true, + Status: model.StatusActive, + } + if err := repo.Create(ctx, user); err != nil { + t.Fatalf("Create = %v", err) + } + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(injectUserIDMiddleware("admin-1")) + r.Use(AdminRequired(repo)) + r.GET("/admin", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/admin", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestAdminRequired_NonAdminForbidden(t *testing.T) { + repo := setupAdminTestDB(t) + ctx := context.Background() + + user := &model.User{ + ID: "user-1", + Username: "regular", + Email: "regular@example.com", + IsAdmin: false, + Status: model.StatusActive, + } + if err := repo.Create(ctx, user); err != nil { + t.Fatalf("Create = %v", err) + } + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(injectUserIDMiddleware("user-1")) + r.Use(AdminRequired(repo)) + r.GET("/admin", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/admin", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden) + } + + var body errorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body.Error.Message != "admin access required" { + t.Errorf("message = %q, want %q", body.Error.Message, "admin access required") + } +} + +func TestAdminRequired_SoftDeletedAdmin(t *testing.T) { + repo := setupAdminTestDB(t) + ctx := context.Background() + + user := &model.User{ + ID: "admin-2", + Username: "deleted_admin", + Email: "deleted_admin@example.com", + IsAdmin: true, + Status: model.StatusActive, + } + if err := repo.Create(ctx, user); err != nil { + t.Fatalf("Create = %v", err) + } + + // Soft-delete the admin user + if err := repo.Delete(ctx, "admin-2"); err != nil { + t.Fatalf("Delete = %v", err) + } + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(injectUserIDMiddleware("admin-2")) + r.Use(AdminRequired(repo)) + r.GET("/admin", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/admin", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +} + +func TestAdminRequired_NoUserID(t *testing.T) { + repo := setupAdminTestDB(t) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(AdminRequired(repo)) + r.GET("/admin", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/admin", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +}