mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-01 05:36:49 -04:00
* 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>
809 lines
29 KiB
Go
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))
|
|
})
|
|
})
|
|
})
|