package auth import ( "context" "encoding/json" "errors" "log/slog" "net/http" "net/http/httptest" "testing" "time" ) // mockMCIAS returns a test HTTP server that simulates MCIAS endpoints. func mockMCIAS(t *testing.T) *httptest.Server { t.Helper() mux := http.NewServeMux() mux.HandleFunc("POST /v1/auth/login", func(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` Password string `json:"password"` ServiceName string `json:"service_name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) return } if req.Username == "admin" && req.Password == "secret" { w.Header().Set("Content-Type", "application/json") exp := time.Now().Add(1 * time.Hour).Format(time.RFC3339) _ = json.NewEncoder(w).Encode(map[string]string{ "token": "tok-admin-123", "expires_at": exp, }) return } if req.Username == "denied" && req.Password == "secret" { http.Error(w, `{"error":"forbidden by policy"}`, http.StatusForbidden) return } http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized) }) mux.HandleFunc("POST /v1/token/validate", func(w http.ResponseWriter, r *http.Request) { var req struct { Token string `json:"token"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) return } switch req.Token { case "tok-admin-123": w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "valid": true, "sub": "uuid-admin", "username": "admin", "account_type": "human", "roles": []string{"admin", "user"}, }) case "tok-user-456": w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "valid": true, "sub": "uuid-user", "username": "alice", "account_type": "human", "roles": []string{"user"}, }) case "tok-expired": w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "valid": false, }) default: http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) } }) mux.HandleFunc("POST /v1/auth/logout", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) return httptest.NewServer(mux) } func newTestAuth(t *testing.T, serverURL string) *Authenticator { t.Helper() a, err := New(Config{ ServerURL: serverURL, ServiceName: "test-service", Tags: []string{"env:test"}, }, slog.Default()) if err != nil { t.Fatalf("New: %v", err) } return a } func TestLogin(t *testing.T) { srv := mockMCIAS(t) defer srv.Close() a := newTestAuth(t, srv.URL) token, exp, err := a.Login("admin", "secret", "") if err != nil { t.Fatalf("Login: %v", err) } if token != "tok-admin-123" { t.Fatalf("token = %q, want %q", token, "tok-admin-123") } if exp.IsZero() { t.Fatal("expiresAt is zero") } } func TestLoginInvalidCredentials(t *testing.T) { srv := mockMCIAS(t) defer srv.Close() a := newTestAuth(t, srv.URL) _, _, err := a.Login("admin", "wrong", "") if err == nil { t.Fatal("expected error for invalid credentials") } if !errors.Is(err, ErrInvalidCredentials) { t.Fatalf("err = %v, want ErrInvalidCredentials", err) } } func TestLoginForbidden(t *testing.T) { srv := mockMCIAS(t) defer srv.Close() a := newTestAuth(t, srv.URL) _, _, err := a.Login("denied", "secret", "") if err == nil { t.Fatal("expected error for forbidden login") } if !errors.Is(err, ErrForbidden) { t.Fatalf("err = %v, want ErrForbidden", err) } } func TestValidateToken(t *testing.T) { srv := mockMCIAS(t) defer srv.Close() a := newTestAuth(t, srv.URL) info, err := a.ValidateToken("tok-admin-123") if err != nil { t.Fatalf("ValidateToken: %v", err) } if info.Username != "admin" { t.Fatalf("Username = %q, want %q", info.Username, "admin") } if info.AccountType != "human" { t.Fatalf("AccountType = %q, want %q", info.AccountType, "human") } if !info.IsAdmin { t.Fatal("IsAdmin = false, want true") } if len(info.Roles) != 2 { t.Fatalf("Roles = %v, want 2 roles", info.Roles) } } func TestValidateTokenNonAdmin(t *testing.T) { srv := mockMCIAS(t) defer srv.Close() a := newTestAuth(t, srv.URL) info, err := a.ValidateToken("tok-user-456") if err != nil { t.Fatalf("ValidateToken: %v", err) } if info.Username != "alice" { t.Fatalf("Username = %q, want %q", info.Username, "alice") } if info.IsAdmin { t.Fatal("IsAdmin = true, want false") } } func TestValidateTokenExpired(t *testing.T) { srv := mockMCIAS(t) defer srv.Close() a := newTestAuth(t, srv.URL) _, err := a.ValidateToken("tok-expired") if err == nil { t.Fatal("expected error for expired token") } if !errors.Is(err, ErrInvalidToken) { t.Fatalf("err = %v, want ErrInvalidToken", err) } } func TestValidateTokenUnknown(t *testing.T) { srv := mockMCIAS(t) defer srv.Close() a := newTestAuth(t, srv.URL) _, err := a.ValidateToken("tok-unknown") if err == nil { t.Fatal("expected error for unknown token") } } func TestValidateTokenCache(t *testing.T) { callCount := 0 mux := http.NewServeMux() mux.HandleFunc("POST /v1/token/validate", func(w http.ResponseWriter, _ *http.Request) { callCount++ w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "valid": true, "username": "cached-user", "roles": []string{"user"}, }) }) srv := httptest.NewServer(mux) defer srv.Close() a := newTestAuth(t, srv.URL) // First call: cache miss, hits server. info1, err := a.ValidateToken("tok-cache-test") if err != nil { t.Fatalf("ValidateToken (1st): %v", err) } if callCount != 1 { t.Fatalf("server calls = %d, want 1", callCount) } // Second call: cache hit, no server call. info2, err := a.ValidateToken("tok-cache-test") if err != nil { t.Fatalf("ValidateToken (2nd): %v", err) } if callCount != 1 { t.Fatalf("server calls = %d, want 1 (cached)", callCount) } if info1.Username != info2.Username { t.Fatalf("cached username mismatch: %q vs %q", info1.Username, info2.Username) } } func TestValidateTokenCacheExpiry(t *testing.T) { callCount := 0 mux := http.NewServeMux() mux.HandleFunc("POST /v1/token/validate", func(w http.ResponseWriter, _ *http.Request) { callCount++ w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "valid": true, "username": "user", "roles": []string{"user"}, }) }) srv := httptest.NewServer(mux) defer srv.Close() a := newTestAuth(t, srv.URL) // Override the cache clock to simulate time passing. now := time.Now() a.cache.now = func() time.Time { return now } _, err := a.ValidateToken("tok-expiry-test") if err != nil { t.Fatalf("ValidateToken (1st): %v", err) } if callCount != 1 { t.Fatalf("server calls = %d, want 1", callCount) } // Advance past cache TTL. now = now.Add(cacheTTL + 1*time.Second) _, err = a.ValidateToken("tok-expiry-test") if err != nil { t.Fatalf("ValidateToken (2nd): %v", err) } if callCount != 2 { t.Fatalf("server calls = %d, want 2 (cache expired)", callCount) } } func TestLogout(t *testing.T) { srv := mockMCIAS(t) defer srv.Close() a := newTestAuth(t, srv.URL) if err := a.Logout("tok-admin-123"); err != nil { t.Fatalf("Logout: %v", err) } } func TestNewRequiresServerURL(t *testing.T) { _, err := New(Config{}, slog.Default()) if err == nil { t.Fatal("expected error for empty server_url") } } func TestContextRoundtrip(t *testing.T) { info := &TokenInfo{ Username: "testuser", Roles: []string{"user"}, IsAdmin: false, } ctx := ContextWithTokenInfo(context.Background(), info) got := TokenInfoFromContext(ctx) if got == nil { t.Fatal("TokenInfoFromContext returned nil") } if got.Username != "testuser" { t.Fatalf("Username = %q, want %q", got.Username, "testuser") } } func TestContextMissing(t *testing.T) { got := TokenInfoFromContext(context.Background()) if got != nil { t.Fatalf("expected nil from empty context, got %v", got) } } func TestAdminDetection(t *testing.T) { tests := []struct { roles []string want bool }{ {[]string{"admin", "user"}, true}, {[]string{"admin"}, true}, {[]string{"user"}, false}, {[]string{}, false}, {nil, false}, } for _, tt := range tests { got := hasRole(tt.roles, "admin") if got != tt.want { t.Errorf("hasRole(%v, admin) = %v, want %v", tt.roles, got, tt.want) } } }