Files
synapbus/internal/auth/user_store_test.go
T
Algis DumbrisandClaude Opus 4.6 8a1c096355 feat: implement human auth with OAuth 2.1 (fosite)
Add complete auth subsystem with OAuth 2.1 authorization server using
ory/fosite, local user accounts with bcrypt password hashing, session
management, and HTTP handlers for the Web UI.

Components:
- User store with bcrypt hashing (configurable cost, default 12), CRUD,
  validation (username 3-64 chars alphanumeric+underscore, password 8-72 bytes)
- Session store with secure random IDs, configurable lifetime (default 24h),
  expiration cleanup, and per-user invalidation
- OAuth client store with client_id/secret generation and bcrypt verification
- Fosite storage adapter implementing CoreStorage, TokenRevocationStorage,
  and PKCERequestStorage backed by SQLite
- OAuth provider configured with authorization code (PKCE S256 mandatory),
  client credentials, refresh token rotation, and token introspection
- HTTP handlers: POST /auth/register, POST /auth/login, POST /auth/logout,
  GET /auth/me, PUT /auth/password, GET /oauth/authorize, POST /oauth/token,
  POST /oauth/introspect
- Middleware: RequireSession (cookie), RequireBearer (access token),
  RequireAuth (either), RequireAdmin (role check)
- Structured auth event logging (login, token issuance, session lifecycle)
- Schema migration 002_auth.sql extending users, oauth_clients, oauth_tokens
  tables and adding sessions, oauth_authorization_codes tables
- Initial admin user auto-created on first run with random password printed
  to stdout
- All tests pass with CGO_ENABLED=0, zero external runtime dependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:59:37 +02:00

290 lines
7.2 KiB
Go

package auth
import (
"context"
"database/sql"
"fmt"
"testing"
_ "modernc.org/sqlite"
"github.com/smart-mcp-proxy/synapbus/internal/storage"
"golang.org/x/crypto/bcrypt"
)
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
t.Fatalf("enable foreign keys: %v", err)
}
ctx := context.Background()
if err := storage.RunMigrations(ctx, db); err != nil {
t.Fatalf("run migrations: %v", err)
}
return db
}
func TestUserStore_CreateUser(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteUserStore(db, 10) // use cost 10 for test speed
ctx := context.Background()
t.Run("valid user", func(t *testing.T) {
user, err := store.CreateUser(ctx, "testuser", "password123", "Test User")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if user.ID == 0 {
t.Error("expected non-zero user ID")
}
if user.Username != "testuser" {
t.Errorf("Username = %q, want %q", user.Username, "testuser")
}
if user.DisplayName != "Test User" {
t.Errorf("DisplayName = %q, want %q", user.DisplayName, "Test User")
}
// First user should be admin
if user.Role != RoleAdmin {
t.Errorf("Role = %q, want %q (first user should be admin)", user.Role, RoleAdmin)
}
})
t.Run("second user is regular", func(t *testing.T) {
user, err := store.CreateUser(ctx, "testuser2", "password123", "Test User 2")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if user.Role != RoleUser {
t.Errorf("Role = %q, want %q (second user should be regular)", user.Role, RoleUser)
}
})
t.Run("bcrypt cost >= 10", func(t *testing.T) {
user, err := store.GetUserByUsername(ctx, "testuser")
if err != nil {
t.Fatalf("GetUserByUsername: %v", err)
}
cost, err := bcrypt.Cost([]byte(user.PasswordHash))
if err != nil {
t.Fatalf("bcrypt.Cost: %v", err)
}
if cost < 10 {
t.Errorf("bcrypt cost = %d, want >= 10", cost)
}
})
}
func TestUserStore_DuplicateUsername(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteUserStore(db, 10)
ctx := context.Background()
if _, err := store.CreateUser(ctx, "dupuser", "password123", ""); err != nil {
t.Fatalf("CreateUser: %v", err)
}
_, err := store.CreateUser(ctx, "dupuser", "password456", "")
if err != ErrDuplicateUsername {
t.Errorf("expected ErrDuplicateUsername, got %v", err)
}
}
func TestUserStore_InvalidUsername(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteUserStore(db, 10)
ctx := context.Background()
tests := []struct {
name string
username string
}{
{"too short", "ab"},
{"has spaces", "hello world"},
{"has special chars", "user@name"},
{"has dash", "user-name"},
{"empty", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := store.CreateUser(ctx, tt.username, "password123", "")
if err != ErrInvalidUsername {
t.Errorf("expected ErrInvalidUsername for %q, got %v", tt.username, err)
}
})
}
}
func TestUserStore_PasswordValidation(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteUserStore(db, 10)
ctx := context.Background()
t.Run("too short", func(t *testing.T) {
_, err := store.CreateUser(ctx, "shortpw", "short", "")
if err != ErrPasswordTooShort {
t.Errorf("expected ErrPasswordTooShort, got %v", err)
}
})
t.Run("too long", func(t *testing.T) {
longPW := make([]byte, 73)
for i := range longPW {
longPW[i] = 'a'
}
_, err := store.CreateUser(ctx, "longpw", string(longPW), "")
if err != ErrPasswordTooLong {
t.Errorf("expected ErrPasswordTooLong, got %v", err)
}
})
}
func TestUserStore_GetByID(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteUserStore(db, 10)
ctx := context.Background()
user, err := store.CreateUser(ctx, "getbyid", "password123", "Get By ID")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
got, err := store.GetUserByID(ctx, user.ID)
if err != nil {
t.Fatalf("GetUserByID: %v", err)
}
if got.Username != "getbyid" {
t.Errorf("Username = %q, want %q", got.Username, "getbyid")
}
// Non-existent user
_, err = store.GetUserByID(ctx, 99999)
if err != ErrUserNotFound {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
}
func TestUserStore_VerifyPassword(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteUserStore(db, 10)
ctx := context.Background()
store.CreateUser(ctx, "verifypw", "correctpassword", "")
t.Run("correct password", func(t *testing.T) {
user, err := store.VerifyPassword(ctx, "verifypw", "correctpassword")
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if user.Username != "verifypw" {
t.Errorf("Username = %q, want %q", user.Username, "verifypw")
}
})
t.Run("wrong password", func(t *testing.T) {
_, err := store.VerifyPassword(ctx, "verifypw", "wrongpassword")
if err != ErrInvalidPassword {
t.Errorf("expected ErrInvalidPassword, got %v", err)
}
})
t.Run("non-existent user", func(t *testing.T) {
_, err := store.VerifyPassword(ctx, "nonexistent", "password")
if err != ErrInvalidPassword {
t.Errorf("expected ErrInvalidPassword, got %v", err)
}
})
}
func TestUserStore_UpdatePassword(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteUserStore(db, 10)
ctx := context.Background()
user, _ := store.CreateUser(ctx, "updatepw", "oldpassword1", "")
t.Run("successful update", func(t *testing.T) {
err := store.UpdatePassword(ctx, user.ID, "newpassword1")
if err != nil {
t.Fatalf("UpdatePassword: %v", err)
}
// Old password should fail
_, err = store.VerifyPassword(ctx, "updatepw", "oldpassword1")
if err != ErrInvalidPassword {
t.Error("old password should not work after update")
}
// New password should work
_, err = store.VerifyPassword(ctx, "updatepw", "newpassword1")
if err != nil {
t.Errorf("new password should work: %v", err)
}
})
t.Run("too short new password", func(t *testing.T) {
err := store.UpdatePassword(ctx, user.ID, "short")
if err != ErrPasswordTooShort {
t.Errorf("expected ErrPasswordTooShort, got %v", err)
}
})
t.Run("non-existent user", func(t *testing.T) {
err := store.UpdatePassword(ctx, 99999, "password123")
if err != ErrUserNotFound {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
})
}
func TestUserStore_ListUsers(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteUserStore(db, 10)
ctx := context.Background()
for i := 0; i < 3; i++ {
store.CreateUser(ctx, fmt.Sprintf("listuser%d", i), "password123", "")
}
users, err := store.ListUsers(ctx)
if err != nil {
t.Fatalf("ListUsers: %v", err)
}
if len(users) != 3 {
t.Errorf("got %d users, want 3", len(users))
}
}
func TestUserStore_CountUsers(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteUserStore(db, 10)
ctx := context.Background()
count, err := store.CountUsers(ctx)
if err != nil {
t.Fatalf("CountUsers: %v", err)
}
if count != 0 {
t.Errorf("initial count = %d, want 0", count)
}
store.CreateUser(ctx, "countuser", "password123", "")
count, err = store.CountUsers(ctx)
if err != nil {
t.Fatalf("CountUsers: %v", err)
}
if count != 1 {
t.Errorf("count after create = %d, want 1", count)
}
}