feat(gateway): add debug logging with file storage and retention feat(gateway): add audit logging for user actions feat(gateway): add request ID tracking and rate limit headers feat(gateway): add model aliases and load balancing strategies feat(gateway): add config hot-reload via SIGHUP feat(gateway): add CORS support feat(gateway): add data export API and dashboard endpoints feat(gateway): add dashboard pages for audit and debug logs feat(gateway): add concurrent request limiting per token feat(gateway): add streaming timeout support feat(gateway): add migration support for new schema fields
300 lines
6.8 KiB
Go
300 lines
6.8 KiB
Go
package auth
|
|
|
|
import (
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func setupTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("opening test db: %v", err)
|
|
}
|
|
|
|
// Create tables
|
|
_, err = db.Exec(`
|
|
CREATE TABLE users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
email TEXT DEFAULT '',
|
|
password_hash TEXT NOT NULL,
|
|
is_admin INTEGER DEFAULT 0,
|
|
totp_secret TEXT DEFAULT '',
|
|
totp_enabled INTEGER DEFAULT 0,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE api_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
key_hash TEXT NOT NULL,
|
|
key_prefix TEXT NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
rate_limit_rpm INTEGER DEFAULT 0,
|
|
daily_budget_usd REAL DEFAULT 0,
|
|
max_concurrent INTEGER DEFAULT 0,
|
|
created_at INTEGER NOT NULL,
|
|
last_used_at INTEGER DEFAULT 0
|
|
);
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("creating tables: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func TestCreateUser(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
store := NewStore(db, nil)
|
|
|
|
user, err := store.CreateUser("alice", "password123", true)
|
|
if err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
if user.Username != "alice" {
|
|
t.Errorf("expected username 'alice', got '%s'", user.Username)
|
|
}
|
|
if !user.IsAdmin {
|
|
t.Error("expected admin user")
|
|
}
|
|
if user.ID == 0 {
|
|
t.Error("expected non-zero ID")
|
|
}
|
|
}
|
|
|
|
func TestGetUserByUsername(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
store := NewStore(db, nil)
|
|
|
|
store.CreateUser("bob", "password123", false)
|
|
|
|
user, err := store.GetUserByUsername("bob")
|
|
if err != nil {
|
|
t.Fatalf("GetUserByUsername: %v", err)
|
|
}
|
|
if user.Username != "bob" {
|
|
t.Errorf("expected 'bob', got '%s'", user.Username)
|
|
}
|
|
|
|
_, err = store.GetUserByUsername("nonexistent")
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent user")
|
|
}
|
|
}
|
|
|
|
func TestCheckPassword(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
store := NewStore(db, nil)
|
|
|
|
store.CreateUser("charlie", "correctpassword", false)
|
|
user, _ := store.GetUserByUsername("charlie")
|
|
|
|
if !store.CheckPassword(user, "correctpassword") {
|
|
t.Error("correct password should match")
|
|
}
|
|
if store.CheckPassword(user, "wrongpassword") {
|
|
t.Error("wrong password should not match")
|
|
}
|
|
}
|
|
|
|
func TestUpdatePassword(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
store := NewStore(db, nil)
|
|
|
|
user, _ := store.CreateUser("dave", "oldpass12", false)
|
|
|
|
if err := store.UpdatePassword(user.ID, "newpass12"); err != nil {
|
|
t.Fatalf("UpdatePassword: %v", err)
|
|
}
|
|
|
|
user, _ = store.GetUserByUsername("dave")
|
|
if store.CheckPassword(user, "oldpass12") {
|
|
t.Error("old password should not work")
|
|
}
|
|
if !store.CheckPassword(user, "newpass12") {
|
|
t.Error("new password should work")
|
|
}
|
|
}
|
|
|
|
func TestDeleteUser(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
store := NewStore(db, nil)
|
|
|
|
user1, _ := store.CreateUser("admin1", "password1234", true)
|
|
user2, _ := store.CreateUser("user2", "password1234", false)
|
|
|
|
// Can delete non-admin
|
|
if err := store.DeleteUser(user2.ID); err != nil {
|
|
t.Fatalf("DeleteUser: %v", err)
|
|
}
|
|
|
|
// Cannot delete last admin
|
|
if err := store.DeleteUser(user1.ID); err == nil {
|
|
t.Error("should not be able to delete last admin")
|
|
}
|
|
}
|
|
|
|
func TestHasAnyUser(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
store := NewStore(db, nil)
|
|
|
|
if store.HasAnyUser() {
|
|
t.Error("should have no users initially")
|
|
}
|
|
|
|
store.CreateUser("first", "password1234", true)
|
|
|
|
if !store.HasAnyUser() {
|
|
t.Error("should have users after creation")
|
|
}
|
|
}
|
|
|
|
func TestSessionCRUD(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
store := NewStore(db, nil)
|
|
|
|
user, _ := store.CreateUser("sessuser", "password1234", false)
|
|
|
|
sessionID, err := store.CreateSession(user.ID, 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
|
|
sess, err := store.GetSession(sessionID)
|
|
if err != nil {
|
|
t.Fatalf("GetSession: %v", err)
|
|
}
|
|
if sess.UserID != user.ID {
|
|
t.Errorf("expected user ID %d, got %d", user.ID, sess.UserID)
|
|
}
|
|
|
|
if err := store.DeleteSession(sessionID); err != nil {
|
|
t.Fatalf("DeleteSession: %v", err)
|
|
}
|
|
|
|
_, err = store.GetSession(sessionID)
|
|
if err == nil {
|
|
t.Error("session should be deleted")
|
|
}
|
|
}
|
|
|
|
func TestStaticTokenLookup(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
|
|
staticTokens := []StaticToken{
|
|
{Name: "test-token", Key: "sk-test-key-12345678", RateLimitRPM: 60, DailyBudgetUSD: 10.0, MaxConcurrent: 5},
|
|
}
|
|
store := NewStore(db, staticTokens)
|
|
|
|
token, err := store.LookupAPIToken("sk-test-key-12345678")
|
|
if err != nil {
|
|
t.Fatalf("LookupAPIToken: %v", err)
|
|
}
|
|
if token.Name != "test-token" {
|
|
t.Errorf("expected 'test-token', got '%s'", token.Name)
|
|
}
|
|
if token.ID != -1 {
|
|
t.Errorf("static token should have ID -1, got %d", token.ID)
|
|
}
|
|
if token.RateLimitRPM != 60 {
|
|
t.Errorf("expected RPM 60, got %d", token.RateLimitRPM)
|
|
}
|
|
if token.MaxConcurrent != 5 {
|
|
t.Errorf("expected max_concurrent 5, got %d", token.MaxConcurrent)
|
|
}
|
|
|
|
// Non-existent token
|
|
_, err = store.LookupAPIToken("nonexistent")
|
|
if err == nil {
|
|
t.Error("should error on nonexistent token")
|
|
}
|
|
}
|
|
|
|
func TestDBTokenCRUD(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
store := NewStore(db, nil)
|
|
|
|
user, _ := store.CreateUser("tokenuser", "password1234", false)
|
|
|
|
plainKey, token, err := store.CreateAPIToken(user.ID, "my-token", 100, 5.0)
|
|
if err != nil {
|
|
t.Fatalf("CreateAPIToken: %v", err)
|
|
}
|
|
if plainKey == "" {
|
|
t.Error("plain key should not be empty")
|
|
}
|
|
if token.Name != "my-token" {
|
|
t.Errorf("expected 'my-token', got '%s'", token.Name)
|
|
}
|
|
|
|
// Lookup by key
|
|
found, err := store.LookupAPIToken(plainKey)
|
|
if err != nil {
|
|
t.Fatalf("LookupAPIToken: %v", err)
|
|
}
|
|
if found.Name != "my-token" {
|
|
t.Errorf("expected 'my-token', got '%s'", found.Name)
|
|
}
|
|
|
|
// List tokens
|
|
tokens, err := store.ListAPITokens(user.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListAPITokens: %v", err)
|
|
}
|
|
if len(tokens) != 1 {
|
|
t.Errorf("expected 1 token, got %d", len(tokens))
|
|
}
|
|
|
|
// Delete
|
|
if err := store.DeleteAPIToken(token.ID); err != nil {
|
|
t.Fatalf("DeleteAPIToken: %v", err)
|
|
}
|
|
|
|
_, err = store.LookupAPIToken(plainKey)
|
|
if err == nil {
|
|
t.Error("token should be deleted")
|
|
}
|
|
}
|
|
|
|
func TestSetStaticTokens(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
|
|
store := NewStore(db, nil)
|
|
|
|
_, err := store.LookupAPIToken("key1")
|
|
if err == nil {
|
|
t.Error("should not find token before setting")
|
|
}
|
|
|
|
store.SetStaticTokens([]StaticToken{
|
|
{Name: "new-token", Key: "key1"},
|
|
})
|
|
|
|
token, err := store.LookupAPIToken("key1")
|
|
if err != nil {
|
|
t.Fatalf("after SetStaticTokens: %v", err)
|
|
}
|
|
if token.Name != "new-token" {
|
|
t.Errorf("expected 'new-token', got '%s'", token.Name)
|
|
}
|
|
}
|