Files
LocalAI/core/http/routes/auth_test.go
Ettore Di Giacinto 59108fbe32 feat: add distributed mode (#9124)
* feat: add distributed mode (experimental)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix data races, mutexes, transactions

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactorings

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix events and tool stream in agent chat

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* use ginkgo

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(cron): compute correctly time boundaries avoiding re-triggering

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* enhancements, refactorings

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* do not flood of healthy checks

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* do not list obvious backends as text backends

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* tests fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Drop redundant healthcheck

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* enhancements, refactorings

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-30 00:47:27 +02:00

809 lines
29 KiB
Go

//go:build auth
package routes_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gorm.io/gorm"
)
func newTestAuthApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
e := echo.New()
// Apply auth middleware
e.Use(auth.Middleware(db, appConfig))
// We can't use routes.RegisterAuthRoutes directly since it needs *application.Application.
// Instead, we register the routes manually for testing.
// GET /api/auth/status
e.GET("/api/auth/status", func(c echo.Context) error {
authEnabled := db != nil
providers := []string{}
hasUsers := false
if authEnabled {
var count int64
db.Model(&auth.User{}).Count(&count)
hasUsers = count > 0
providers = append(providers, auth.ProviderLocal)
if appConfig.Auth.GitHubClientID != "" {
providers = append(providers, auth.ProviderGitHub)
}
}
resp := map[string]any{
"authEnabled": authEnabled,
"providers": providers,
"hasUsers": hasUsers,
}
user := auth.GetUser(c)
if user != nil {
resp["user"] = map[string]any{
"id": user.ID,
"role": user.Role,
}
} else {
resp["user"] = nil
}
return c.JSON(http.StatusOK, resp)
})
// POST /api/auth/register
e.POST("/api/auth/register", func(c echo.Context) error {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
body.Email = strings.TrimSpace(body.Email)
body.Name = strings.TrimSpace(body.Name)
if body.Email == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "email is required"})
}
if len(body.Password) < 8 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "password must be at least 8 characters"})
}
var existing auth.User
if err := db.Where("email = ? AND provider = ?", body.Email, auth.ProviderLocal).First(&existing).Error; err == nil {
return c.JSON(http.StatusConflict, map[string]string{"error": "an account with this email already exists"})
}
hash, err := auth.HashPassword(body.Password)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
}
role := auth.AssignRole(db, body.Email, appConfig.Auth.AdminEmail)
status := auth.StatusActive
if appConfig.Auth.RegistrationMode == "approval" && role != auth.RoleAdmin {
status = auth.StatusPending
}
name := body.Name
if name == "" {
name = body.Email
}
user := &auth.User{
ID: uuid.New().String(), Email: body.Email, Name: name,
Provider: auth.ProviderLocal, Subject: body.Email, PasswordHash: hash,
Role: role, Status: status,
}
if err := db.Create(user).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create user"})
}
if status == auth.StatusPending {
return c.JSON(http.StatusOK, map[string]any{"message": "registration successful, awaiting admin approval", "pending": true})
}
sessionID, err := auth.CreateSession(db, user.ID, "")
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
}
auth.SetSessionCookie(c, sessionID)
return c.JSON(http.StatusCreated, map[string]any{
"user": map[string]any{"id": user.ID, "email": user.Email, "name": user.Name, "role": user.Role},
})
})
// POST /api/auth/login - inline test handler
e.POST("/api/auth/login", func(c echo.Context) error {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
body.Email = strings.TrimSpace(body.Email)
if body.Email == "" || body.Password == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "email and password are required"})
}
var user auth.User
if err := db.Where("email = ? AND provider = ?", body.Email, auth.ProviderLocal).First(&user).Error; err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
}
if !auth.CheckPassword(user.PasswordHash, body.Password) {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
}
if user.Status == auth.StatusPending {
return c.JSON(http.StatusForbidden, map[string]string{"error": "account pending admin approval"})
}
auth.MaybePromote(db, &user, appConfig.Auth.AdminEmail)
sessionID, err := auth.CreateSession(db, user.ID, "")
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
}
auth.SetSessionCookie(c, sessionID)
return c.JSON(http.StatusOK, map[string]any{
"user": map[string]any{"id": user.ID, "email": user.Email, "name": user.Name, "role": user.Role},
})
})
// POST /api/auth/logout
e.POST("/api/auth/logout", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
if cookie, err := c.Cookie("session"); err == nil && cookie.Value != "" {
auth.DeleteSession(db, cookie.Value, "")
}
auth.ClearSessionCookie(c)
return c.JSON(http.StatusOK, map[string]string{"message": "logged out"})
})
// GET /api/auth/me
e.GET("/api/auth/me", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
return c.JSON(http.StatusOK, map[string]any{
"id": user.ID,
"email": user.Email,
"role": user.Role,
})
})
// POST /api/auth/api-keys
e.POST("/api/auth/api-keys", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
var body struct {
Name string `json:"name"`
}
if err := c.Bind(&body); err != nil || body.Name == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name is required"})
}
plaintext, record, err := auth.CreateAPIKey(db, user.ID, body.Name, user.Role, "", nil)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create API key"})
}
return c.JSON(http.StatusCreated, map[string]any{
"key": plaintext,
"id": record.ID,
"name": record.Name,
"keyPrefix": record.KeyPrefix,
})
})
// GET /api/auth/api-keys
e.GET("/api/auth/api-keys", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
keys, err := auth.ListAPIKeys(db, user.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list API keys"})
}
result := make([]map[string]any, 0, len(keys))
for _, k := range keys {
result = append(result, map[string]any{
"id": k.ID,
"name": k.Name,
"keyPrefix": k.KeyPrefix,
})
}
return c.JSON(http.StatusOK, map[string]any{"keys": result})
})
// DELETE /api/auth/api-keys/:id
e.DELETE("/api/auth/api-keys/:id", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
keyID := c.Param("id")
if err := auth.RevokeAPIKey(db, keyID, user.ID); err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "API key not found"})
}
return c.JSON(http.StatusOK, map[string]string{"message": "API key revoked"})
})
// Admin: GET /api/auth/admin/users
adminMw := auth.RequireAdmin()
e.GET("/api/auth/admin/users", func(c echo.Context) error {
var users []auth.User
db.Order("created_at ASC").Find(&users)
result := make([]map[string]any, 0, len(users))
for _, u := range users {
result = append(result, map[string]any{"id": u.ID, "role": u.Role, "email": u.Email})
}
return c.JSON(http.StatusOK, map[string]any{"users": result})
}, adminMw)
// Admin: PUT /api/auth/admin/users/:id/role
e.PUT("/api/auth/admin/users/:id/role", func(c echo.Context) error {
currentUser := auth.GetUser(c)
targetID := c.Param("id")
if currentUser.ID == targetID {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot change your own role"})
}
var body struct {
Role string `json:"role"`
}
if err := c.Bind(&body); err != nil || (body.Role != auth.RoleAdmin && body.Role != auth.RoleUser) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "role must be 'admin' or 'user'"})
}
result := db.Model(&auth.User{}).Where("id = ?", targetID).Update("role", body.Role)
if result.RowsAffected == 0 {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
return c.JSON(http.StatusOK, map[string]string{"message": "role updated"})
}, adminMw)
// Admin: DELETE /api/auth/admin/users/:id
e.DELETE("/api/auth/admin/users/:id", func(c echo.Context) error {
currentUser := auth.GetUser(c)
targetID := c.Param("id")
if currentUser.ID == targetID {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete yourself"})
}
db.Where("user_id = ?", targetID).Delete(&auth.Session{})
db.Where("user_id = ?", targetID).Delete(&auth.UserAPIKey{})
result := db.Where("id = ?", targetID).Delete(&auth.User{})
if result.RowsAffected == 0 {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
return c.JSON(http.StatusOK, map[string]string{"message": "user deleted"})
}, adminMw)
// Regular API endpoint for testing
e.POST("/v1/chat/completions", func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
e.GET("/v1/models", func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
return e
}
// Helper to create test user
func createRouteTestUser(db *gorm.DB, email, role string) *auth.User {
user := &auth.User{
ID: "user-" + email,
Email: email,
Name: "Test " + role,
Provider: auth.ProviderGitHub,
Subject: "sub-" + email,
Role: role,
Status: auth.StatusActive,
}
Expect(db.Create(user).Error).ToNot(HaveOccurred())
return user
}
func doAuthRequest(e *echo.Echo, method, path string, body []byte, opts ...func(*http.Request)) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
} else {
req = httptest.NewRequest(method, path, nil)
}
req.Header.Set("Content-Type", "application/json")
for _, opt := range opts {
opt(req)
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
func withSession(sessionID string) func(*http.Request) {
return func(req *http.Request) {
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
}
}
func withBearer(token string) func(*http.Request) {
return func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+token)
}
}
var _ = Describe("Auth Routes", Label("auth"), func() {
var (
db *gorm.DB
appConfig *config.ApplicationConfig
)
BeforeEach(func() {
var err error
db, err = auth.InitDB(":memory:")
Expect(err).ToNot(HaveOccurred())
appConfig = config.NewApplicationConfig()
appConfig.Auth.Enabled = true
appConfig.Auth.GitHubClientID = "test-client-id"
})
Context("GET /api/auth/status", func() {
It("returns authEnabled=true and provider list when auth enabled", func() {
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
Expect(rec.Code).To(Equal(http.StatusOK))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
Expect(resp["authEnabled"]).To(BeTrue())
providers := resp["providers"].([]any)
Expect(providers).To(ContainElement(auth.ProviderGitHub))
})
It("returns authEnabled=false when auth disabled", func() {
app := newTestAuthApp(nil, config.NewApplicationConfig())
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
Expect(rec.Code).To(Equal(http.StatusOK))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
Expect(resp["authEnabled"]).To(BeFalse())
})
It("returns user info when authenticated", func() {
user := createRouteTestUser(db, "status@test.com", auth.RoleAdmin)
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "GET", "/api/auth/status", nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusOK))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
Expect(resp["user"]).ToNot(BeNil())
})
It("returns user=null when not authenticated", func() {
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
Expect(rec.Code).To(Equal(http.StatusOK))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
Expect(resp["user"]).To(BeNil())
})
It("returns hasUsers=false on fresh DB", func() {
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
Expect(resp["hasUsers"]).To(BeFalse())
})
})
Context("POST /api/auth/logout", func() {
It("deletes session and clears cookie", func() {
user := createRouteTestUser(db, "logout@test.com", auth.RoleUser)
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "POST", "/api/auth/logout", nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusOK))
// Session should be deleted
validatedUser, _ := auth.ValidateSession(db, sessionID, "")
Expect(validatedUser).To(BeNil())
})
It("returns 401 when not authenticated", func() {
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "POST", "/api/auth/logout", nil)
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
})
Context("GET /api/auth/me", func() {
It("returns current user profile", func() {
user := createRouteTestUser(db, "me@test.com", auth.RoleAdmin)
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "GET", "/api/auth/me", nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusOK))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
Expect(resp["email"]).To(Equal("me@test.com"))
Expect(resp["role"]).To(Equal("admin"))
})
It("returns 401 when not authenticated", func() {
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "GET", "/api/auth/me", nil)
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
})
Context("POST /api/auth/api-keys", func() {
It("creates API key and returns plaintext once", func() {
user := createRouteTestUser(db, "apikey@test.com", auth.RoleUser)
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"name": "my key"})
rec := doAuthRequest(app, "POST", "/api/auth/api-keys", body, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusCreated))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
Expect(resp["key"]).To(HavePrefix("lai-"))
Expect(resp["name"]).To(Equal("my key"))
})
It("key is usable for authentication", func() {
user := createRouteTestUser(db, "apikey2@test.com", auth.RoleUser)
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"name": "usable key"})
rec := doAuthRequest(app, "POST", "/api/auth/api-keys", body, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusCreated))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
apiKey := resp["key"].(string)
// Use the key for API access
rec = doAuthRequest(app, "GET", "/v1/models", nil, withBearer(apiKey))
Expect(rec.Code).To(Equal(http.StatusOK))
})
It("returns 401 when not authenticated", func() {
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"name": "test"})
rec := doAuthRequest(app, "POST", "/api/auth/api-keys", body)
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
})
Context("GET /api/auth/api-keys", func() {
It("lists user's API keys without plaintext", func() {
user := createRouteTestUser(db, "list@test.com", auth.RoleUser)
auth.CreateAPIKey(db, user.ID, "key1", auth.RoleUser, "", nil)
auth.CreateAPIKey(db, user.ID, "key2", auth.RoleUser, "", nil)
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "GET", "/api/auth/api-keys", nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusOK))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
keys := resp["keys"].([]any)
Expect(keys).To(HaveLen(2))
})
It("does not show other users' keys", func() {
user1 := createRouteTestUser(db, "user1@test.com", auth.RoleUser)
user2 := createRouteTestUser(db, "user2@test.com", auth.RoleUser)
auth.CreateAPIKey(db, user1.ID, "user1-key", auth.RoleUser, "", nil)
auth.CreateAPIKey(db, user2.ID, "user2-key", auth.RoleUser, "", nil)
sessionID, _ := auth.CreateSession(db, user1.ID, "")
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "GET", "/api/auth/api-keys", nil, withSession(sessionID))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
keys := resp["keys"].([]any)
Expect(keys).To(HaveLen(1))
})
})
Context("DELETE /api/auth/api-keys/:id", func() {
It("revokes user's own key", func() {
user := createRouteTestUser(db, "revoke@test.com", auth.RoleUser)
plaintext, record, err := auth.CreateAPIKey(db, user.ID, "to-revoke", auth.RoleUser, "", nil)
Expect(err).ToNot(HaveOccurred())
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "DELETE", "/api/auth/api-keys/"+record.ID, nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusOK))
// Key should no longer work
rec = doAuthRequest(app, "GET", "/v1/models", nil, withBearer(plaintext))
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
It("returns 404 for another user's key", func() {
user1 := createRouteTestUser(db, "owner@test.com", auth.RoleUser)
user2 := createRouteTestUser(db, "attacker@test.com", auth.RoleUser)
_, record, _ := auth.CreateAPIKey(db, user1.ID, "secret-key", auth.RoleUser, "", nil)
sessionID, _ := auth.CreateSession(db, user2.ID, "")
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "DELETE", "/api/auth/api-keys/"+record.ID, nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusNotFound))
})
})
Context("Admin: GET /api/auth/admin/users", func() {
It("returns all users for admin", func() {
admin := createRouteTestUser(db, "admin@test.com", auth.RoleAdmin)
createRouteTestUser(db, "user@test.com", auth.RoleUser)
sessionID, _ := auth.CreateSession(db, admin.ID, "")
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "GET", "/api/auth/admin/users", nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusOK))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
users := resp["users"].([]any)
Expect(users).To(HaveLen(2))
})
It("returns 403 for non-admin user", func() {
user := createRouteTestUser(db, "nonadmin@test.com", auth.RoleUser)
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "GET", "/api/auth/admin/users", nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusForbidden))
})
})
Context("Admin: PUT /api/auth/admin/users/:id/role", func() {
It("changes user role", func() {
admin := createRouteTestUser(db, "admin2@test.com", auth.RoleAdmin)
user := createRouteTestUser(db, "promote@test.com", auth.RoleUser)
sessionID, _ := auth.CreateSession(db, admin.ID, "")
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"role": "admin"})
rec := doAuthRequest(app, "PUT", "/api/auth/admin/users/"+user.ID+"/role", body, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusOK))
// Verify in DB
var updated auth.User
db.First(&updated, "id = ?", user.ID)
Expect(updated.Role).To(Equal(auth.RoleAdmin))
})
It("prevents self-demotion", func() {
admin := createRouteTestUser(db, "self-demote@test.com", auth.RoleAdmin)
sessionID, _ := auth.CreateSession(db, admin.ID, "")
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"role": "user"})
rec := doAuthRequest(app, "PUT", "/api/auth/admin/users/"+admin.ID+"/role", body, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
It("returns 403 for non-admin", func() {
user := createRouteTestUser(db, "sneaky@test.com", auth.RoleUser)
other := createRouteTestUser(db, "victim@test.com", auth.RoleUser)
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"role": "admin"})
rec := doAuthRequest(app, "PUT", "/api/auth/admin/users/"+other.ID+"/role", body, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusForbidden))
})
})
Context("Admin: DELETE /api/auth/admin/users/:id", func() {
It("deletes user and cascades to sessions + API keys", func() {
admin := createRouteTestUser(db, "admin3@test.com", auth.RoleAdmin)
target := createRouteTestUser(db, "delete-me@test.com", auth.RoleUser)
auth.CreateSession(db, target.ID, "")
auth.CreateAPIKey(db, target.ID, "target-key", auth.RoleUser, "", nil)
sessionID, _ := auth.CreateSession(db, admin.ID, "")
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusOK))
// User should be gone
var count int64
db.Model(&auth.User{}).Where("id = ?", target.ID).Count(&count)
Expect(count).To(Equal(int64(0)))
// Sessions and keys should be gone
db.Model(&auth.Session{}).Where("user_id = ?", target.ID).Count(&count)
Expect(count).To(Equal(int64(0)))
db.Model(&auth.UserAPIKey{}).Where("user_id = ?", target.ID).Count(&count)
Expect(count).To(Equal(int64(0)))
})
It("prevents self-deletion", func() {
admin := createRouteTestUser(db, "admin4@test.com", auth.RoleAdmin)
sessionID, _ := auth.CreateSession(db, admin.ID, "")
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+admin.ID, nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
It("returns 403 for non-admin", func() {
user := createRouteTestUser(db, "sneak@test.com", auth.RoleUser)
target := createRouteTestUser(db, "target2@test.com", auth.RoleUser)
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusForbidden))
})
})
Context("POST /api/auth/register", func() {
It("registers first user as admin", func() {
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"email": "first@test.com", "password": "password123", "name": "First User"})
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
Expect(rec.Code).To(Equal(http.StatusCreated))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
user := resp["user"].(map[string]any)
Expect(user["role"]).To(Equal("admin"))
Expect(user["email"]).To(Equal("first@test.com"))
// Session cookie should be set
cookies := rec.Result().Cookies()
found := false
for _, c := range cookies {
if c.Name == "session" && c.Value != "" {
found = true
}
}
Expect(found).To(BeTrue())
})
It("registers second user as regular user", func() {
createRouteTestUser(db, "existing@test.com", auth.RoleAdmin)
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"email": "second@test.com", "password": "password123"})
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
Expect(rec.Code).To(Equal(http.StatusCreated))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
user := resp["user"].(map[string]any)
Expect(user["role"]).To(Equal("user"))
})
It("rejects duplicate email", func() {
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"email": "dup@test.com", "password": "password123"})
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
Expect(rec.Code).To(Equal(http.StatusCreated))
rec = doAuthRequest(app, "POST", "/api/auth/register", body)
Expect(rec.Code).To(Equal(http.StatusConflict))
})
It("rejects short password", func() {
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"email": "short@test.com", "password": "1234567"})
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
It("rejects empty email", func() {
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"email": "", "password": "password123"})
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
It("returns pending when registration mode is approval", func() {
createRouteTestUser(db, "admin-existing@test.com", auth.RoleAdmin)
appConfig.Auth.RegistrationMode = "approval"
defer func() { appConfig.Auth.RegistrationMode = "" }()
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"email": "pending@test.com", "password": "password123"})
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
Expect(rec.Code).To(Equal(http.StatusOK))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
Expect(resp["pending"]).To(BeTrue())
})
})
Context("POST /api/auth/login", func() {
It("logs in with correct credentials", func() {
app := newTestAuthApp(db, appConfig)
// Register first
body, _ := json.Marshal(map[string]string{"email": "login@test.com", "password": "password123"})
doAuthRequest(app, "POST", "/api/auth/register", body)
// Login
body, _ = json.Marshal(map[string]string{"email": "login@test.com", "password": "password123"})
rec := doAuthRequest(app, "POST", "/api/auth/login", body)
Expect(rec.Code).To(Equal(http.StatusOK))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
user := resp["user"].(map[string]any)
Expect(user["email"]).To(Equal("login@test.com"))
})
It("rejects wrong password", func() {
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"email": "wrong@test.com", "password": "password123"})
doAuthRequest(app, "POST", "/api/auth/register", body)
body, _ = json.Marshal(map[string]string{"email": "wrong@test.com", "password": "wrongpassword"})
rec := doAuthRequest(app, "POST", "/api/auth/login", body)
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
It("rejects non-existent user", func() {
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"email": "nobody@test.com", "password": "password123"})
rec := doAuthRequest(app, "POST", "/api/auth/login", body)
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
It("rejects pending user", func() {
createRouteTestUser(db, "admin-for-pending@test.com", auth.RoleAdmin)
appConfig.Auth.RegistrationMode = "approval"
defer func() { appConfig.Auth.RegistrationMode = "" }()
app := newTestAuthApp(db, appConfig)
body, _ := json.Marshal(map[string]string{"email": "pending-login@test.com", "password": "password123"})
doAuthRequest(app, "POST", "/api/auth/register", body)
appConfig.Auth.RegistrationMode = ""
body, _ = json.Marshal(map[string]string{"email": "pending-login@test.com", "password": "password123"})
rec := doAuthRequest(app, "POST", "/api/auth/login", body)
Expect(rec.Code).To(Equal(http.StatusForbidden))
})
})
Context("GET /api/auth/status providers", func() {
It("includes local provider when auth is enabled", func() {
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
Expect(rec.Code).To(Equal(http.StatusOK))
var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
providers := resp["providers"].([]any)
Expect(providers).To(ContainElement(auth.ProviderLocal))
Expect(providers).To(ContainElement(auth.ProviderGitHub))
})
})
})