Files
LocalAI/core/http/routes/auth_test.go
LocalAI [bot] f15b9178ec feat(usage): track and visualise usage per API key (#9920)
* 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>
2026-05-21 16:34:02 +02:00

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(&quotas)
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)))
})
})
})