mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-07 08:16:53 -04:00
* feat(usage): add Source, APIKeyID, APIKeyName columns to UsageRecord Adds three additive columns plus UsageSource* constants. The columns are auto-migrated by InitDB. APIKeyID is a nullable foreign reference to UserAPIKey.ID; APIKeyName is snapshotted on each row so revoked keys keep showing their name in history. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(usage): backfill Source on pre-feature usage rows InitDB now classifies any pre-existing usage_record with an empty source: 'legacy-api-key' user -> legacy, everything else -> web. The backfill is idempotent (only touches NULL/empty rows). Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(usage): add GetUserUsageBySource aggregator Groups by (bucket, source, api_key_id, api_key_name). Filters out legacy by default. Returns both per-bucket detail and roll-ups (by_source, by_key sorted desc and capped at 200, grand_total). The MAX(created_at) projection is iterated via Rows().Scan into a string column and parsed manually because the SQLite driver surfaces the aggregated timestamp as a string, which database/sql refuses to scan directly into time.Time. Postgres returns a real timestamp; the same string path handles its RFC3339 form too. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(usage): log Rows() errors and assert LastUsed in tests Adds rows.Err() and Rows() open-failure logging in computeSourceTotals so silent data drops surface in logs. Logs on parseLastUsedString format misses for the same reason. Strengthens the snapshot-survival test to assert LastUsed is a recent timestamp, locking the SQLite time-string parser behaviour. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(usage): add admin GetAllUsageBySource with filters and truncation Optional user_id and api_key_id filters (composed with AND). Legacy bucket is included for admin callers. truncated=true when more than 200 distinct keys would be in the by_key roll-up. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(auth): plumb auth_source and auth_apikey through Echo context tryAuthenticate now sets auth_source on every successful branch (web for session/Bearer-session, apikey for Bearer-key/x-api-key/ token-cookie, legacy for legacy env key match). For named-key branches it also stores the resolved *UserAPIKey under auth_apikey so downstream middlewares can snapshot id+name without re-validating. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(auth): expand tryAuthenticate godoc and cover Bearer-session branch Documents all three context-keys side effects (auth_source, auth_apikey, _auth_session) plus the split of responsibilities with the parent Middleware. Adds a test for the Bearer-as-session-token classification so future regressions there fail loudly. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(usage): UsageMiddleware records source + snapshots key name Reads auth_source and auth_apikey from the Echo context (set by auth.Middleware in the previous task). Snapshots UserAPIKey.ID and Name onto each row so revoked keys remain readable in history. Falls back to source=web when no auth_source is set (auth disabled or unrecognised path). Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(usage): add /api/auth/usage/sources and admin variant Self endpoint filters legacy server-side; admin endpoint includes legacy and accepts user_id + api_key_id filters. Response includes buckets, totals.{by_source, by_key, grand_total}, and a truncated flag set when the per-key roll-up was capped at 200. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(routes): mark test mirror handlers as keep-in-sync with production The newTestAuthApp helper duplicates production route handlers inline because it cannot use RegisterAuthRoutes (which requires a *application.Application). Naming the source path on each mirror makes the drift contract explicit for future maintainers. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): add usageApi.getMySources/getAdminSources + i18n strings Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): add Sources tab skeleton with data fetch Adds Usage page tab that fetches /api/auth/usage/sources (or the admin variant). Renders raw totals plus a placeholder key list; real visualisations land in subsequent commits. Restructures the existing tab button block so Models and Sources are visible to non-admins (Users remains admin-only). Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): source mix ribbon + searchable/sortable sources table Replaces the SourcesTab placeholder rendering with two reusable components: SourceMixRibbon (one segmented bar per source class) and SourcesTable (search + sort + revoked-key dim). Pulls the current API key list to detect revoked keys. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): skip revoked-key detection until the key list is known existingKeyIds defaulted to an empty Set, which made every live api_key row render as (revoked) during the brief window before apiKeysApi.list() resolved, and permanently after a fetch failure. Use null as the unknown state and suppress the revoked badge until the parent provides a real Set. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): top-N stacked time chart and drill-in chip for Sources tab Top 7 sources by total tokens get distinct colours; the rest roll up into 'Other'. Clicking a row in the SourcesTable dims everything except that series in the chart; the chip is the canonical clear. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(usage): document per-API-key Sources tab and endpoints Extends features/authentication.md Usage Tracking section with: - A 'Sources' tab description and source-class taxonomy - Endpoint documentation for /api/auth/usage/sources and the admin variant - Response shape example with by_source / by_key / grand_total - Migration note about pre-feature row backfill Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(usage): silence errcheck on deferred rows.Close CI errcheck flagged the bare 'defer rows.Close()' in computeSourceTotals. Wrap in a closure that discards the close error explicitly; an error here is non-actionable since we have already drained the rows and logged any iteration failure. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(usage): bound batcher intake and add Shutdown/FlushNow hooks The pre-existing usage batcher had no cap on its add() path; the usageMaxPending=5000 constant only guarded the re-queue path after a failed write, leaving memory growth unbounded if the DB fell behind. This commit: - Adds the cap to add() so saturation drops new records (rate-limited warn at 1/1024) instead of growing unbounded. - Raises usageMaxPending to 50000 to absorb realistic inference bursts. - Replaces the package-level batcher global with a mutex-guarded pair plus a currentBatcher() accessor so Init / Shutdown cycles are race-free. - Adds ShutdownUsageRecorder() for graceful drain on process exit (not yet wired into app shutdown, just published). - Adds FlushNow() for deterministic tests; the middleware suite no longer needs 6s sleeps per spec and now runs in ~50ms instead of 18s. - Re-queue on failed flush is now cap-aware: prepends as much of the failed batch as fits alongside concurrent arrivals, instead of dropping the whole batch when full. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(usage): drain usage batcher on graceful shutdown Registers ShutdownUsageRecorder with the existing signals.RegisterGracefulTerminationHandler so SIGINT/SIGTERM synchronously flushes any in-memory usage records before the process exits. Without this, up to one flush interval (5s) of recorded usage was lost when LocalAI restarted. Refs: #9862 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1080 lines
40 KiB
Go
1080 lines
40 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)
|
|
|
|
// Mirror of production handler in routes/auth.go GET /api/auth/usage/sources.
|
|
// Keep this body in sync with the real handler; this test app cannot call
|
|
// RegisterAuthRoutes because it needs a *application.Application.
|
|
e.GET("/api/auth/usage/sources", func(c echo.Context) error {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
|
}
|
|
period := c.QueryParam("period")
|
|
if period == "" {
|
|
period = "month"
|
|
}
|
|
buckets, totals, err := auth.GetUserUsageBySource(db, user.ID, period)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get usage"})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"buckets": buckets, "totals": totals, "truncated": false,
|
|
})
|
|
})
|
|
|
|
// Mirror of production handler in routes/auth.go GET /api/auth/admin/usage/sources.
|
|
// Keep this body in sync with the real handler.
|
|
e.GET("/api/auth/admin/usage/sources", func(c echo.Context) error {
|
|
period := c.QueryParam("period")
|
|
if period == "" {
|
|
period = "month"
|
|
}
|
|
userID := c.QueryParam("user_id")
|
|
apiKeyID := c.QueryParam("api_key_id")
|
|
buckets, totals, truncated, err := auth.GetAllUsageBySource(db, period, userID, apiKeyID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get usage"})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"buckets": buckets, "totals": totals, "truncated": truncated,
|
|
})
|
|
}, 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))
|
|
})
|
|
})
|
|
|
|
Describe("GET /api/auth/usage/sources", func() {
|
|
It("returns only the caller's data, never legacy", func() {
|
|
app := newTestAuthApp(db, appConfig)
|
|
|
|
alice := createRouteTestUser(db, "alice@example.com", auth.RoleUser)
|
|
aliceToken, err := auth.CreateSession(db, alice.ID, "")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
keyID := "k-alice"
|
|
now := time.Now()
|
|
Expect(auth.RecordUsage(db, &auth.UsageRecord{
|
|
UserID: alice.ID, Source: auth.UsageSourceAPIKey,
|
|
APIKeyID: &keyID, APIKeyName: "alice-key",
|
|
Model: "gpt-4", TotalTokens: 100, CreatedAt: now,
|
|
})).To(Succeed())
|
|
Expect(auth.RecordUsage(db, &auth.UsageRecord{
|
|
UserID: alice.ID, Source: auth.UsageSourceWeb,
|
|
Model: "gpt-4", TotalTokens: 50, CreatedAt: now,
|
|
})).To(Succeed())
|
|
Expect(auth.RecordUsage(db, &auth.UsageRecord{
|
|
UserID: "legacy-api-key", Source: auth.UsageSourceLegacy,
|
|
Model: "gpt-4", TotalTokens: 30, CreatedAt: now,
|
|
})).To(Succeed())
|
|
|
|
rec := doAuthRequest(app, http.MethodGet, "/api/auth/usage/sources?period=month", nil, withSession(aliceToken))
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
var resp struct {
|
|
Buckets []auth.UsageBucket `json:"buckets"`
|
|
Totals auth.SourceTotals `json:"totals"`
|
|
Truncated bool `json:"truncated"`
|
|
}
|
|
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
|
|
_, hasLegacy := resp.Totals.BySource[auth.UsageSourceLegacy]
|
|
Expect(hasLegacy).To(BeFalse())
|
|
Expect(resp.Totals.GrandTotal.Tokens).To(Equal(int64(150)))
|
|
Expect(resp.Truncated).To(BeFalse())
|
|
})
|
|
|
|
It("returns 401 when unauthenticated", func() {
|
|
app := newTestAuthApp(db, appConfig)
|
|
|
|
// Without a session cookie or bearer token, the global auth middleware
|
|
// should refuse the request before our handler runs.
|
|
rec := doAuthRequest(app, http.MethodGet, "/api/auth/usage/sources?period=month", nil)
|
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
|
})
|
|
})
|
|
|
|
Describe("GET /api/auth/admin/usage/sources", func() {
|
|
It("returns 403 for non-admin", func() {
|
|
app := newTestAuthApp(db, appConfig)
|
|
|
|
alice := createRouteTestUser(db, "alice@example.com", auth.RoleUser)
|
|
aliceToken, _ := auth.CreateSession(db, alice.ID, "")
|
|
|
|
rec := doAuthRequest(app, http.MethodGet, "/api/auth/admin/usage/sources?period=month", nil, withSession(aliceToken))
|
|
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
|
})
|
|
|
|
It("returns legacy bucket for admin and applies api_key_id filter", func() {
|
|
app := newTestAuthApp(db, appConfig)
|
|
|
|
admin := createRouteTestUser(db, "admin@example.com", auth.RoleAdmin)
|
|
adminToken, _ := auth.CreateSession(db, admin.ID, "")
|
|
|
|
k1 := "k1"
|
|
k2 := "k2"
|
|
now := time.Now()
|
|
Expect(auth.RecordUsage(db, &auth.UsageRecord{UserID: "alice", Source: auth.UsageSourceAPIKey, APIKeyID: &k1, APIKeyName: "ci", Model: "gpt-4", TotalTokens: 10, CreatedAt: now})).To(Succeed())
|
|
Expect(auth.RecordUsage(db, &auth.UsageRecord{UserID: "alice", Source: auth.UsageSourceAPIKey, APIKeyID: &k2, APIKeyName: "lap", Model: "gpt-4", TotalTokens: 20, CreatedAt: now})).To(Succeed())
|
|
Expect(auth.RecordUsage(db, &auth.UsageRecord{UserID: "legacy-api-key", Source: auth.UsageSourceLegacy, Model: "gpt-4", TotalTokens: 5, CreatedAt: now})).To(Succeed())
|
|
|
|
rec := doAuthRequest(app, http.MethodGet,
|
|
"/api/auth/admin/usage/sources?period=month&api_key_id=k2", nil, withSession(adminToken))
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
var resp struct {
|
|
Totals auth.SourceTotals `json:"totals"`
|
|
Truncated bool `json:"truncated"`
|
|
}
|
|
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
|
|
Expect(resp.Totals.GrandTotal.Tokens).To(Equal(int64(20)))
|
|
})
|
|
|
|
It("includes legacy in by_source for admin with no filter", func() {
|
|
app := newTestAuthApp(db, appConfig)
|
|
|
|
admin := createRouteTestUser(db, "admin@example.com", auth.RoleAdmin)
|
|
adminToken, _ := auth.CreateSession(db, admin.ID, "")
|
|
|
|
now := time.Now()
|
|
Expect(auth.RecordUsage(db, &auth.UsageRecord{UserID: "legacy-api-key", Source: auth.UsageSourceLegacy, Model: "gpt-4", TotalTokens: 7, CreatedAt: now})).To(Succeed())
|
|
|
|
rec := doAuthRequest(app, http.MethodGet, "/api/auth/admin/usage/sources?period=month", nil, withSession(adminToken))
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
|
|
var resp struct {
|
|
Totals auth.SourceTotals `json:"totals"`
|
|
}
|
|
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
|
|
Expect(resp.Totals.BySource).To(HaveKey(auth.UsageSourceLegacy))
|
|
Expect(resp.Totals.BySource[auth.UsageSourceLegacy].Tokens).To(Equal(int64(7)))
|
|
})
|
|
})
|
|
})
|