mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-17 13:10:23 -04:00
* fix(auth): cascade user deletion across all owned data on PostgreSQL Deleting a user from the admin UI in distributed mode (PostgreSQL auth DB) returned "user not found" even when the user clearly existed. The old handler ignored result.Error and only checked RowsAffected, so a foreign-key constraint violation surfaced as a misleading 404. Two issues drove this: 1. invite_codes.created_by / used_by reference users(id) but the InviteCode model declared the FKs without ON DELETE CASCADE. On PostgreSQL the engine therefore rejected the user delete with NO ACTION whenever the user had ever issued or consumed an invite. On SQLite (default in single-node mode) FKs are not enforced, so the bug never appeared there. 2. Several owned tables were never cleaned up regardless of dialect: user_permissions and quota_rules relied on CASCADE that does not fire under SQLite, and usage_records have no FK at all and were left orphaned in every dialect. Introduce auth.DeleteUserCascade which runs the full cleanup in a single transaction: drop invites authored by the user, NULL used_by on invites they consumed (preserves the audit trail), and explicitly wipe sessions, API keys, permissions, quota rules, and usage metrics before deleting the user. The in-memory quota cache is invalidated after commit so a recreated user with the same id never sees stale entries. The HTTP handler now maps the helper's errors to proper status codes — real failures surface as 500 with the cause instead of being swallowed as "not found". Add Ginkgo regression coverage in core/http/auth/users_test.go and core/http/routes/auth_test.go covering invite cleanup, used_by null-out, full data wipe, and the FK-enforced original failure mode (via PRAGMA foreign_keys=ON to mirror PostgreSQL behavior on SQLite). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7 [Claude Code] * chore(deps): bump LocalAGI/LocalRecall — pull in go-fitz PDF extraction Pulls LocalAGI@main (facd888) and LocalRecall@v0.6.0. The latter swaps PDF text extraction from dslipak/pdf to gen2brain/go-fitz (libmupdf bindings) and wraps it in a 60s goroutine timeout — previously certain PDFs (broken xref tables, encrypted, image-only without OCR) would hang indefinitely inside r.GetPlainText() and poison the upload queue. Pure dep bump, no LocalAI source changes. Indirect graph picks up go-fitz + purego + ffi; drops dslipak/pdf. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
935 lines
34 KiB
Go
935 lines
34 KiB
Go
//go:build auth
|
|
|
|
package routes_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"time"
|
|
|
|
"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,
|
|
"staticApiKeyRequired": !authEnabled && len(appConfig.ApiKeys) > 0,
|
|
"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"})
|
|
}
|
|
if err := auth.DeleteUserCascade(db, targetID); err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete user: " + err.Error()})
|
|
}
|
|
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())
|
|
})
|
|
|
|
It("returns staticApiKeyRequired=true when no DB but API keys configured", func() {
|
|
cfg := config.NewApplicationConfig()
|
|
config.WithApiKeys([]string{"test-key-123"})(cfg)
|
|
app := newTestAuthApp(nil, cfg)
|
|
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())
|
|
Expect(resp["staticApiKeyRequired"]).To(BeTrue())
|
|
})
|
|
|
|
It("returns staticApiKeyRequired=false when no DB and no API keys", 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["staticApiKeyRequired"]).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))
|
|
})
|
|
|
|
// Regression coverage for the production bug: in distributed mode the
|
|
// auth DB is PostgreSQL, which strictly enforces foreign keys. The old
|
|
// handler did not clean up invite_codes, user_permissions, quota_rules,
|
|
// or usage_records, which either caused FK violations (surfaced as a
|
|
// misleading 404 "user not found") or left orphan rows after delete.
|
|
It("removes invite codes the user authored", func() {
|
|
admin := createRouteTestUser(db, "admin-inv-author@test.com", auth.RoleAdmin)
|
|
target := createRouteTestUser(db, "deletes-author@test.com", auth.RoleAdmin)
|
|
Expect(db.Create(&auth.InviteCode{
|
|
ID: uuid.New().String(), Code: "code-authored", CodePrefix: "code-aut",
|
|
CreatedBy: target.ID, ExpiresAt: time.Now().Add(time.Hour),
|
|
}).Error).ToNot(HaveOccurred())
|
|
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))
|
|
|
|
var count int64
|
|
db.Model(&auth.InviteCode{}).Where("created_by = ?", target.ID).Count(&count)
|
|
Expect(count).To(Equal(int64(0)))
|
|
})
|
|
|
|
It("nulls used_by on invite codes the user consumed", func() {
|
|
admin := createRouteTestUser(db, "admin-inv-consumer@test.com", auth.RoleAdmin)
|
|
target := createRouteTestUser(db, "deletes-consumer@test.com", auth.RoleUser)
|
|
usedBy := target.ID
|
|
now := time.Now()
|
|
Expect(db.Create(&auth.InviteCode{
|
|
ID: uuid.New().String(), Code: "code-used", CodePrefix: "code-use",
|
|
CreatedBy: admin.ID, UsedBy: &usedBy, UsedAt: &now,
|
|
ExpiresAt: now.Add(time.Hour),
|
|
}).Error).ToNot(HaveOccurred())
|
|
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))
|
|
|
|
// Audit row stays, but no longer points to the deleted user.
|
|
var stale int64
|
|
db.Model(&auth.InviteCode{}).Where("used_by = ?", target.ID).Count(&stale)
|
|
Expect(stale).To(Equal(int64(0)))
|
|
var total int64
|
|
db.Model(&auth.InviteCode{}).Where("created_by = ?", admin.ID).Count(&total)
|
|
Expect(total).To(Equal(int64(1)))
|
|
})
|
|
|
|
It("wipes permissions, quotas, and usage metrics", func() {
|
|
admin := createRouteTestUser(db, "admin-clean@test.com", auth.RoleAdmin)
|
|
target := createRouteTestUser(db, "deletes-clean@test.com", auth.RoleUser)
|
|
|
|
Expect(auth.UpdateUserPermissions(db, target.ID, auth.PermissionMap{auth.FeatureChat: true})).ToNot(HaveOccurred())
|
|
max := int64(100)
|
|
_, err := auth.CreateOrUpdateQuotaRule(db, target.ID, "", &max, nil, 3600)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(auth.RecordUsage(db, &auth.UsageRecord{
|
|
UserID: target.ID, UserName: target.Name, Model: "test-model",
|
|
Endpoint: "/v1/chat/completions", PromptTokens: 5, CompletionTokens: 10, TotalTokens: 15,
|
|
})).ToNot(HaveOccurred())
|
|
|
|
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))
|
|
|
|
var perms, quotas, usage int64
|
|
db.Model(&auth.UserPermission{}).Where("user_id = ?", target.ID).Count(&perms)
|
|
db.Model(&auth.QuotaRule{}).Where("user_id = ?", target.ID).Count("as)
|
|
db.Model(&auth.UsageRecord{}).Where("user_id = ?", target.ID).Count(&usage)
|
|
Expect(perms).To(Equal(int64(0)))
|
|
Expect(quotas).To(Equal(int64(0)))
|
|
Expect(usage).To(Equal(int64(0)))
|
|
})
|
|
|
|
It("returns 200 even when foreign keys are enforced and the user authored invites", func() {
|
|
// Mirror PostgreSQL's strict FK behavior on the SQLite test DB. This
|
|
// exercises exactly the production failure: without the cleanup,
|
|
// the user delete would be rejected by the engine and the handler
|
|
// would surface a misleading 404.
|
|
Expect(db.Exec("PRAGMA foreign_keys = ON").Error).ToNot(HaveOccurred())
|
|
|
|
admin := createRouteTestUser(db, "admin-fk@test.com", auth.RoleAdmin)
|
|
target := createRouteTestUser(db, "deletes-fk@test.com", auth.RoleAdmin)
|
|
Expect(db.Create(&auth.InviteCode{
|
|
ID: uuid.New().String(), Code: "code-fk", CodePrefix: "code-fk1",
|
|
CreatedBy: target.ID, ExpiresAt: time.Now().Add(time.Hour),
|
|
}).Error).ToNot(HaveOccurred())
|
|
|
|
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), "body=%s", rec.Body.String())
|
|
|
|
var users int64
|
|
db.Model(&auth.User{}).Where("id = ?", target.ID).Count(&users)
|
|
Expect(users).To(Equal(int64(0)))
|
|
})
|
|
})
|
|
|
|
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))
|
|
})
|
|
})
|
|
})
|