- Add new test files for auth, service, and handler modules - Improve test organization and coverage - Refactor code for better maintainability - Add captcha, settings, stats, and theme handler tests - Add auth module tests (CAS, OAuth, password, SSO, state) - Add service layer tests for auth, export, permissions, roles - All Go tests pass (exit code 0) - All frontend tests pass (325 tests in 59 files)
551 lines
13 KiB
Go
551 lines
13 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNewSSOManager(t *testing.T) {
|
|
m := NewSSOManager()
|
|
if m == nil {
|
|
t.Fatal("NewSSOManager() returned nil")
|
|
}
|
|
if m.sessions == nil {
|
|
t.Error("NewSSOManager() did not initialize sessions map")
|
|
}
|
|
}
|
|
|
|
func TestGenerateSecureToken(t *testing.T) {
|
|
token, err := generateSecureToken(32)
|
|
if err != nil {
|
|
t.Fatalf("generateSecureToken() error = %v", err)
|
|
}
|
|
if len(token) != 32 {
|
|
t.Errorf("generateSecureToken() length = %d, want 32", len(token))
|
|
}
|
|
|
|
// Generate another token and verify they're different
|
|
token2, err := generateSecureToken(32)
|
|
if err != nil {
|
|
t.Fatalf("generateSecureToken() error = %v", err)
|
|
}
|
|
if token == token2 {
|
|
t.Error("generateSecureToken() generated identical tokens")
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_GenerateAuthorizationCode(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
code, err := m.GenerateAuthorizationCode("client-1", "https://example.com/callback", "openid", 123, "testuser")
|
|
if err != nil {
|
|
t.Fatalf("GenerateAuthorizationCode() error = %v", err)
|
|
}
|
|
if code == "" {
|
|
t.Error("GenerateAuthorizationCode() returned empty code")
|
|
}
|
|
|
|
// Verify session was stored
|
|
m.mu.RLock()
|
|
_, exists := m.sessions[code]
|
|
m.mu.RUnlock()
|
|
|
|
if !exists {
|
|
t.Error("GenerateAuthorizationCode() did not store session")
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_ValidateAuthorizationCode(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
// Generate a code first
|
|
code, _ := m.GenerateAuthorizationCode("client-1", "https://example.com/callback", "openid", 123, "testuser")
|
|
|
|
session, err := m.ValidateAuthorizationCode(code)
|
|
if err != nil {
|
|
t.Fatalf("ValidateAuthorizationCode() error = %v", err)
|
|
}
|
|
|
|
if session.UserID != 123 {
|
|
t.Errorf("UserID = %d, want 123", session.UserID)
|
|
}
|
|
if session.Username != "testuser" {
|
|
t.Errorf("Username = %s, want testuser", session.Username)
|
|
}
|
|
if session.ClientID != "client-1" {
|
|
t.Errorf("ClientID = %s, want client-1", session.ClientID)
|
|
}
|
|
|
|
// Code should be consumed (one-time use)
|
|
_, err = m.ValidateAuthorizationCode(code)
|
|
if err == nil {
|
|
t.Error("ValidateAuthorizationCode() should return error for consumed code")
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_ValidateAuthorizationCode_Invalid(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
_, err := m.ValidateAuthorizationCode("invalid-code")
|
|
if err == nil {
|
|
t.Error("ValidateAuthorizationCode() should return error for invalid code")
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_ValidateAuthorizationCode_Expired(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
// Generate a code
|
|
code, _ := m.GenerateAuthorizationCode("client-1", "https://example.com/callback", "openid", 123, "testuser")
|
|
|
|
// Manually expire it
|
|
m.mu.Lock()
|
|
session := m.sessions[code]
|
|
session.ExpiresAt = time.Now().Add(-1 * time.Hour)
|
|
m.mu.Unlock()
|
|
|
|
_, err := m.ValidateAuthorizationCode(code)
|
|
if err == nil {
|
|
t.Error("ValidateAuthorizationCode() should return error for expired code")
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_GenerateAccessToken(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
session := &SSOSession{
|
|
UserID: 123,
|
|
Username: "testuser",
|
|
Scope: "openid",
|
|
}
|
|
|
|
token, expiresAt, err := m.GenerateAccessToken("client-1", session)
|
|
if err != nil {
|
|
t.Fatalf("GenerateAccessToken() error = %v", err)
|
|
}
|
|
if token == "" {
|
|
t.Error("GenerateAccessToken() returned empty token")
|
|
}
|
|
if expiresAt.Before(time.Now()) {
|
|
t.Error("GenerateAccessToken() returned expired time")
|
|
}
|
|
|
|
// Verify token was stored
|
|
m.mu.RLock()
|
|
storedSession, exists := m.sessions[token]
|
|
m.mu.RUnlock()
|
|
|
|
if !exists {
|
|
t.Error("GenerateAccessToken() did not store session")
|
|
}
|
|
if storedSession.UserID != 123 {
|
|
t.Errorf("Stored UserID = %d, want 123", storedSession.UserID)
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_IntrospectToken(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
session := &SSOSession{
|
|
UserID: 123,
|
|
Username: "testuser",
|
|
Scope: "openid",
|
|
}
|
|
token, _, _ := m.GenerateAccessToken("client-1", session)
|
|
|
|
info, err := m.IntrospectToken(token)
|
|
if err != nil {
|
|
t.Fatalf("IntrospectToken() error = %v", err)
|
|
}
|
|
|
|
if !info.Active {
|
|
t.Error("IntrospectToken() returned inactive for valid token")
|
|
}
|
|
if info.UserID != 123 {
|
|
t.Errorf("UserID = %d, want 123", info.UserID)
|
|
}
|
|
if info.Username != "testuser" {
|
|
t.Errorf("Username = %s, want testuser", info.Username)
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_IntrospectToken_Invalid(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
info, err := m.IntrospectToken("invalid-token")
|
|
if err != nil {
|
|
t.Fatalf("IntrospectToken() error = %v", err)
|
|
}
|
|
|
|
if info.Active {
|
|
t.Error("IntrospectToken() should return inactive for invalid token")
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_IntrospectToken_Expired(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
session := &SSOSession{
|
|
UserID: 123,
|
|
Username: "testuser",
|
|
Scope: "openid",
|
|
}
|
|
token, _, _ := m.GenerateAccessToken("client-1", session)
|
|
|
|
// Manually expire it
|
|
m.mu.Lock()
|
|
m.sessions[token].ExpiresAt = time.Now().Add(-1 * time.Hour)
|
|
m.mu.Unlock()
|
|
|
|
info, err := m.IntrospectToken(token)
|
|
if err != nil {
|
|
t.Fatalf("IntrospectToken() error = %v", err)
|
|
}
|
|
|
|
if info.Active {
|
|
t.Error("IntrospectToken() should return inactive for expired token")
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_RevokeToken(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
session := &SSOSession{
|
|
UserID: 123,
|
|
Username: "testuser",
|
|
Scope: "openid",
|
|
}
|
|
token, _, _ := m.GenerateAccessToken("client-1", session)
|
|
|
|
err := m.RevokeToken(token)
|
|
if err != nil {
|
|
t.Fatalf("RevokeToken() error = %v", err)
|
|
}
|
|
|
|
// Token should be removed
|
|
m.mu.RLock()
|
|
_, exists := m.sessions[token]
|
|
m.mu.RUnlock()
|
|
|
|
if exists {
|
|
t.Error("RevokeToken() did not remove token")
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_CleanupExpired(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
// Add sessions
|
|
session1 := &SSOSession{
|
|
UserID: 123,
|
|
Username: "user1",
|
|
Scope: "openid",
|
|
CreatedAt: time.Now(),
|
|
ExpiresAt: time.Now().Add(1 * time.Hour), // Valid
|
|
}
|
|
session2 := &SSOSession{
|
|
UserID: 456,
|
|
Username: "user2",
|
|
Scope: "openid",
|
|
CreatedAt: time.Now(),
|
|
ExpiresAt: time.Now().Add(-1 * time.Hour), // Expired
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.sessions["valid-token"] = session1
|
|
m.sessions["expired-token"] = session2
|
|
m.mu.Unlock()
|
|
|
|
m.CleanupExpired()
|
|
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
// Valid session should remain
|
|
if _, exists := m.sessions["valid-token"]; !exists {
|
|
t.Error("CleanupExpired() removed valid session")
|
|
}
|
|
|
|
// Expired session should be removed
|
|
if _, exists := m.sessions["expired-token"]; exists {
|
|
t.Error("CleanupExpired() did not remove expired session")
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_evictOldest(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
// Add sessions with different creation times
|
|
oldSession := &SSOSession{
|
|
UserID: 123,
|
|
Username: "old-user",
|
|
Scope: "openid",
|
|
CreatedAt: time.Now().Add(-1 * time.Hour),
|
|
ExpiresAt: time.Now().Add(1 * time.Hour),
|
|
}
|
|
newSession := &SSOSession{
|
|
UserID: 456,
|
|
Username: "new-user",
|
|
Scope: "openid",
|
|
CreatedAt: time.Now(),
|
|
ExpiresAt: time.Now().Add(1 * time.Hour),
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.sessions["old-token"] = oldSession
|
|
m.sessions["new-token"] = newSession
|
|
m.mu.Unlock()
|
|
|
|
m.mu.Lock()
|
|
m.evictOldest()
|
|
m.mu.Unlock()
|
|
|
|
// Oldest session should be removed
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
if _, exists := m.sessions["old-token"]; exists {
|
|
t.Error("evictOldest() did not remove oldest session")
|
|
}
|
|
if _, exists := m.sessions["new-token"]; !exists {
|
|
t.Error("evictOldest() removed newer session")
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_evictOldest_Empty(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
// Should not panic with empty sessions
|
|
m.mu.Lock()
|
|
m.evictOldest()
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
func TestSSOManager_SessionCount(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
if m.SessionCount() != 0 {
|
|
t.Errorf("SessionCount() = %d, want 0", m.SessionCount())
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.sessions["token1"] = &SSOSession{UserID: 1}
|
|
m.sessions["token2"] = &SSOSession{UserID: 2}
|
|
m.mu.Unlock()
|
|
|
|
if m.SessionCount() != 2 {
|
|
t.Errorf("SessionCount() = %d, want 2", m.SessionCount())
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_StartCleanup(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
m.StartCleanup(ctx)
|
|
|
|
// Add an expired session
|
|
m.mu.Lock()
|
|
m.sessions["expired"] = &SSOSession{
|
|
UserID: 1,
|
|
ExpiresAt: time.Now().Add(-1 * time.Hour),
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
// Let cleanup run
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Cancel context to stop cleanup
|
|
cancel()
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
func TestSSOManager_MaxSessions(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
// Fill up sessions to max
|
|
for i := 0; i < MaxSessions; i++ {
|
|
token, _ := generateSecureToken(32)
|
|
m.mu.Lock()
|
|
m.sessions[token] = &SSOSession{
|
|
UserID: int64(i),
|
|
CreatedAt: time.Now().Add(-time.Duration(i) * time.Second),
|
|
ExpiresAt: time.Now().Add(1 * time.Hour),
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// Generate one more - should trigger eviction
|
|
code, err := m.GenerateAuthorizationCode("client-1", "https://example.com/callback", "openid", 99999, "newuser")
|
|
if err != nil {
|
|
t.Fatalf("GenerateAuthorizationCode() error = %v", err)
|
|
}
|
|
|
|
// New session should exist
|
|
m.mu.RLock()
|
|
_, exists := m.sessions[code]
|
|
m.mu.RUnlock()
|
|
|
|
if !exists {
|
|
t.Error("GenerateAuthorizationCode() did not store session at max capacity")
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_GenerateAccessToken_MaxSessions(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
// Fill up sessions to max
|
|
for i := 0; i < MaxSessions; i++ {
|
|
token, _ := generateSecureToken(32)
|
|
m.mu.Lock()
|
|
m.sessions[token] = &SSOSession{
|
|
UserID: int64(i),
|
|
CreatedAt: time.Now().Add(-time.Duration(i) * time.Second),
|
|
ExpiresAt: time.Now().Add(1 * time.Hour),
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// Generate access token - should trigger eviction
|
|
session := &SSOSession{
|
|
UserID: 99999,
|
|
Username: "newuser",
|
|
Scope: "openid",
|
|
}
|
|
|
|
token, expiresAt, err := m.GenerateAccessToken("client-1", session)
|
|
if err != nil {
|
|
t.Fatalf("GenerateAccessToken() error = %v", err)
|
|
}
|
|
if token == "" {
|
|
t.Error("GenerateAccessToken() returned empty token")
|
|
}
|
|
if expiresAt.Before(time.Now()) {
|
|
t.Error("GenerateAccessToken() returned expired time")
|
|
}
|
|
|
|
// Verify token was stored
|
|
m.mu.RLock()
|
|
_, exists := m.sessions[token]
|
|
m.mu.RUnlock()
|
|
|
|
if !exists {
|
|
t.Error("GenerateAccessToken() did not store session at max capacity")
|
|
}
|
|
}
|
|
|
|
func TestSSOManager_GenerateAccessToken_WithExpiredSessions(t *testing.T) {
|
|
m := NewSSOManager()
|
|
|
|
// Add some expired sessions
|
|
for i := 0; i < 5; i++ {
|
|
token, _ := generateSecureToken(32)
|
|
m.mu.Lock()
|
|
m.sessions[token] = &SSOSession{
|
|
UserID: int64(i),
|
|
CreatedAt: time.Now().Add(-2 * time.Hour),
|
|
ExpiresAt: time.Now().Add(-1 * time.Hour), // Expired
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// Generate access token - should clean up expired sessions first
|
|
session := &SSOSession{
|
|
UserID: 123,
|
|
Username: "testuser",
|
|
Scope: "openid",
|
|
}
|
|
|
|
_, _, err := m.GenerateAccessToken("client-1", session)
|
|
if err != nil {
|
|
t.Fatalf("GenerateAccessToken() error = %v", err)
|
|
}
|
|
|
|
// Verify expired sessions were cleaned
|
|
m.mu.RLock()
|
|
count := len(m.sessions)
|
|
m.mu.RUnlock()
|
|
|
|
if count > MaxSessions {
|
|
t.Errorf("Session count %d exceeds max %d", count, MaxSessions)
|
|
}
|
|
}
|
|
|
|
// DefaultSSOClientsStore tests
|
|
|
|
func TestNewDefaultSSOClientsStore(t *testing.T) {
|
|
store := NewDefaultSSOClientsStore()
|
|
if store == nil {
|
|
t.Fatal("NewDefaultSSOClientsStore() returned nil")
|
|
}
|
|
if store.clients == nil {
|
|
t.Error("NewDefaultSSOClientsStore() did not initialize clients map")
|
|
}
|
|
}
|
|
|
|
func TestDefaultSSOClientsStore_RegisterClient(t *testing.T) {
|
|
store := NewDefaultSSOClientsStore()
|
|
|
|
client := &SSOClient{
|
|
ClientID: "client-1",
|
|
ClientSecret: "secret",
|
|
Name: "Test Client",
|
|
RedirectURIs: []string{"https://example.com/callback"},
|
|
}
|
|
|
|
store.RegisterClient(client)
|
|
|
|
retrieved, err := store.GetByClientID("client-1")
|
|
if err != nil {
|
|
t.Fatalf("GetByClientID() error = %v", err)
|
|
}
|
|
if retrieved.Name != "Test Client" {
|
|
t.Errorf("Name = %s, want Test Client", retrieved.Name)
|
|
}
|
|
}
|
|
|
|
func TestDefaultSSOClientsStore_GetByClientID_NotFound(t *testing.T) {
|
|
store := NewDefaultSSOClientsStore()
|
|
|
|
_, err := store.GetByClientID("non-existent")
|
|
if err == nil {
|
|
t.Error("GetByClientID() should return error for non-existent client")
|
|
}
|
|
}
|
|
|
|
func TestDefaultSSOClientsStore_ValidateClientRedirectURI(t *testing.T) {
|
|
store := NewDefaultSSOClientsStore()
|
|
|
|
client := &SSOClient{
|
|
ClientID: "client-1",
|
|
ClientSecret: "secret",
|
|
Name: "Test Client",
|
|
RedirectURIs: []string{"https://example.com/callback", "https://app.com/auth"},
|
|
}
|
|
store.RegisterClient(client)
|
|
|
|
tests := []struct {
|
|
name string
|
|
clientID string
|
|
redirectURI string
|
|
want bool
|
|
}{
|
|
{"valid URI", "client-1", "https://example.com/callback", true},
|
|
{"another valid URI", "client-1", "https://app.com/auth", true},
|
|
{"invalid URI", "client-1", "https://evil.com/callback", false},
|
|
{"invalid client", "unknown", "https://example.com/callback", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := store.ValidateClientRedirectURI(tt.clientID, tt.redirectURI)
|
|
if result != tt.want {
|
|
t.Errorf("ValidateClientRedirectURI() = %v, want %v", result, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|