package service import ( "context" "encoding/base64" "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "wucher/internal/config" "wucher/internal/resilience" ) func TestNewMicrosoftSSO(t *testing.T) { cfg := config.MicrosoftSSOConfig{ TenantID: "tenant-id", ClientID: "client-id", ClientSecret: "secret", RedirectURL: "http://localhost/callback", Authority: "https://login.microsoftonline.com/", Scopes: []string{"openid", "profile", "email"}, } client := NewMicrosoftSSO(cfg) if client == nil { t.Fatalf("expected client") } if client.cfg.ClientID != cfg.ClientID { t.Fatalf("client config not set") } if client.client == nil { t.Fatalf("http client should not be nil") } if client.client.Timeout != 10*time.Second { t.Fatalf("unexpected timeout: %v", client.client.Timeout) } } func TestMicrosoftSSO_AuthCodeURL(t *testing.T) { cfg := config.MicrosoftSSOConfig{ TenantID: "tenant-id", ClientID: "client-id", RedirectURL: "http://localhost/callback", Authority: "https://login.microsoftonline.com/", Scopes: []string{"openid", "profile", "email"}, } client := NewMicrosoftSSO(cfg) raw := client.AuthCodeURL("state-123") parsed, err := url.Parse(raw) if err != nil { t.Fatalf("parse url: %v", err) } if parsed.Path != "/tenant-id/oauth2/v2.0/authorize" { t.Fatalf("unexpected authorize path: %s", parsed.Path) } q := parsed.Query() if q.Get("client_id") != "client-id" || q.Get("response_type") != "code" || q.Get("redirect_uri") != "http://localhost/callback" || q.Get("response_mode") != "query" || q.Get("scope") != "openid profile email offline_access User.Read" || q.Get("prompt") != "select_account" || q.Get("state") != "state-123" { t.Fatalf("unexpected query values: %v", q) } if got := q.Get("max_age"); got != "" { t.Fatalf("max_age should not be set, got %q", got) } } func TestMicrosoftSSO_LogoutURL(t *testing.T) { cfg := config.MicrosoftSSOConfig{ TenantID: "tenant-id", ClientID: "client-id", RedirectURL: "http://localhost/callback", Authority: "https://login.microsoftonline.com/", Scopes: []string{"openid", "profile", "email"}, } client := NewMicrosoftSSO(cfg) raw := client.LogoutURL("http://localhost/logout-done") parsed, err := url.Parse(raw) if err != nil { t.Fatalf("parse url: %v", err) } if parsed.Path != "/tenant-id/oauth2/v2.0/logout" { t.Fatalf("unexpected logout path: %s", parsed.Path) } if got := parsed.Query().Get("post_logout_redirect_uri"); got != "http://localhost/logout-done" { t.Fatalf("unexpected post logout redirect: %q", got) } } func TestMicrosoftSSO_ExchangeCode(t *testing.T) { t.Run("missing code", func(t *testing.T) { client := NewMicrosoftSSO(config.MicrosoftSSOConfig{}) if _, err := client.ExchangeCode(context.Background(), ""); err == nil { t.Fatalf("expected error for missing code") } }) t.Run("token endpoint non 2xx", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) })) defer server.Close() client := NewMicrosoftSSO(config.MicrosoftSSOConfig{ TenantID: "tenant-id", ClientID: "client-id", ClientSecret: "secret", RedirectURL: "http://localhost/callback", Authority: server.URL + "/", Scopes: []string{"openid"}, }) if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "token exchange failed") { t.Fatalf("expected token exchange failed error, got %v", err) } }) t.Run("invalid json response", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{invalid`)) })) defer server.Close() client := NewMicrosoftSSO(config.MicrosoftSSOConfig{ TenantID: "tenant-id", ClientID: "client-id", ClientSecret: "secret", RedirectURL: "http://localhost/callback", Authority: server.URL + "/", Scopes: []string{"openid"}, }) if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil { t.Fatalf("expected unmarshal error") } }) t.Run("missing id token", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"access_token":"abc"}`)) })) defer server.Close() client := NewMicrosoftSSO(config.MicrosoftSSOConfig{ TenantID: "tenant-id", ClientID: "client-id", ClientSecret: "secret", RedirectURL: "http://localhost/callback", Authority: server.URL + "/", Scopes: []string{"openid"}, }) if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "missing id_token") { t.Fatalf("expected missing id_token error, got %v", err) } }) t.Run("invalid id token claims", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id_token":"invalid-token"}`)) })) defer server.Close() client := NewMicrosoftSSO(config.MicrosoftSSOConfig{ TenantID: "tenant-id", ClientID: "client-id", ClientSecret: "secret", RedirectURL: "http://localhost/callback", Authority: server.URL + "/", Scopes: []string{"openid"}, }) if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil { t.Fatalf("expected parse jwt error") } }) t.Run("success with email claim", func(t *testing.T) { idToken := makeUnsignedJWT(map[string]any{ "sub": "subject-1", "email": "user@example.com", "name": "John Doe", }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Fatalf("expected POST") } if r.URL.Path != "/tenant-id/oauth2/v2.0/token" { t.Fatalf("unexpected path: %s", r.URL.Path) } if err := r.ParseForm(); err != nil { t.Fatalf("parse form: %v", err) } if r.PostForm.Get("client_id") != "client-id" || r.PostForm.Get("client_secret") != "secret" || r.PostForm.Get("grant_type") != "authorization_code" || r.PostForm.Get("code") != "code-1" || r.PostForm.Get("redirect_uri") != "http://localhost/callback" || r.PostForm.Get("scope") != "openid profile email offline_access User.Read" { t.Fatalf("unexpected form values: %v", r.PostForm) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"id_token": idToken, "access_token": "at-1"}) })) defer server.Close() client := NewMicrosoftSSO(config.MicrosoftSSOConfig{ TenantID: "tenant-id", ClientID: "client-id", ClientSecret: "secret", RedirectURL: "http://localhost/callback", Authority: server.URL + "/", Scopes: []string{"openid", "profile", "email"}, }) claims, err := client.ExchangeCode(context.Background(), "code-1") if err != nil { t.Fatalf("exchange code failed: %v", err) } if claims.Subject != "subject-1" || claims.Email != "user@example.com" || claims.Name != "John Doe" { t.Fatalf("unexpected claims: %+v", claims) } }) t.Run("success fallback preferred username", func(t *testing.T) { idToken := makeUnsignedJWT(map[string]any{ "sub": "subject-2", "preferred_username": "fallback@example.com", "name": "Jane Doe", }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"id_token": idToken, "access_token": "at-2"}) })) defer server.Close() client := NewMicrosoftSSO(config.MicrosoftSSOConfig{ TenantID: "tenant-id", ClientID: "client-id", ClientSecret: "secret", RedirectURL: "http://localhost/callback", Authority: server.URL + "/", Scopes: []string{"openid"}, }) claims, err := client.ExchangeCode(context.Background(), "code-1") if err != nil { t.Fatalf("exchange code failed: %v", err) } if claims.Email != "fallback@example.com" { t.Fatalf("expected preferred_username fallback, got %q", claims.Email) } }) t.Run("http client do error", func(t *testing.T) { client := NewMicrosoftSSO(config.MicrosoftSSOConfig{ TenantID: "tenant-id", ClientID: "client-id", ClientSecret: "secret", RedirectURL: "http://localhost/callback", Authority: "https://example.com/", Scopes: []string{"openid"}, }) client.client = &http.Client{ Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("network down") }), } if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "network down") { t.Fatalf("expected http client error, got %v", err) } }) } func TestMicrosoftSSO_ExchangeCode_CircuitBreaker(t *testing.T) { newExecutor := func() *resilience.Executor { return resilience.NewExecutor("microsoft_sso", config.CircuitBreakerPolicy{ Enabled: true, Timeout: time.Second, MinRequests: 2, FailureRatio: 0.5, ConsecutiveFailures: 2, }, slog.New(slog.NewJSONHandler(io.Discard, nil)), resilience.ClassifyMicrosoftSSOError) } t.Run("client errors are excluded", func(t *testing.T) { hits := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ http.Error(w, "bad request", http.StatusBadRequest) })) defer server.Close() client := NewMicrosoftSSO(config.MicrosoftSSOConfig{ TenantID: "tenant-id", ClientID: "client-id", ClientSecret: "secret", RedirectURL: "http://localhost/callback", Authority: server.URL + "/", Scopes: []string{"openid"}, }).WithExecutor(newExecutor()) for range 3 { if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "token exchange failed") { t.Fatalf("expected token exchange failed error, got %v", err) } } if hits != 3 { t.Fatalf("expected breaker to stay closed for excluded client errors, got %d hits", hits) } }) t.Run("server errors open the breaker", func(t *testing.T) { hits := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ http.Error(w, "unavailable", http.StatusServiceUnavailable) })) defer server.Close() client := NewMicrosoftSSO(config.MicrosoftSSOConfig{ TenantID: "tenant-id", ClientID: "client-id", ClientSecret: "secret", RedirectURL: "http://localhost/callback", Authority: server.URL + "/", Scopes: []string{"openid"}, }).WithExecutor(newExecutor()) for range 2 { if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "token exchange failed") { t.Fatalf("expected token exchange failed error, got %v", err) } } _, err := client.ExchangeCode(context.Background(), "code-1") var openErr *resilience.OpenError if !errors.As(err, &openErr) { t.Fatalf("expected circuit open error, got %v", err) } if hits != 2 { t.Fatalf("expected breaker to fail fast after opening, got %d hits", hits) } }) } func TestParseJWTClaims(t *testing.T) { t.Run("invalid jwt format", func(t *testing.T) { if _, err := parseJWTClaims("invalid"); err == nil { t.Fatalf("expected invalid jwt error") } }) t.Run("invalid payload encoding", func(t *testing.T) { if _, err := parseJWTClaims("header.%%%.sig"); err == nil { t.Fatalf("expected decode error") } }) t.Run("invalid payload json", func(t *testing.T) { payload := base64.RawURLEncoding.EncodeToString([]byte("not-json")) if _, err := parseJWTClaims("header." + payload + ".sig"); err == nil { t.Fatalf("expected json unmarshal error") } }) t.Run("success", func(t *testing.T) { token := makeUnsignedJWT(map[string]any{ "sub": "subject-1", "email": "user@example.com", "name": "John Doe", }) claims, err := parseJWTClaims(token) if err != nil { t.Fatalf("parse claims failed: %v", err) } if claims["sub"] != "subject-1" || claims["email"] != "user@example.com" { t.Fatalf("unexpected claims: %+v", claims) } }) } func TestDecodeSegment(t *testing.T) { t.Run("success", func(t *testing.T) { raw := base64.RawURLEncoding.EncodeToString([]byte("hello")) got, err := decodeSegment(raw) if err != nil { t.Fatalf("decode failed: %v", err) } if string(got) != "hello" { t.Fatalf("unexpected value: %q", string(got)) } }) t.Run("success url safe alphabet", func(t *testing.T) { got, err := decodeSegment("--__") if err != nil { t.Fatalf("decode failed: %v", err) } want := []byte{0xfb, 0xef, 0xff} if len(got) != len(want) { t.Fatalf("unexpected length: %d", len(got)) } for i := range want { if got[i] != want[i] { t.Fatalf("unexpected byte at %d: got %x want %x", i, got[i], want[i]) } } }) t.Run("invalid base64", func(t *testing.T) { if _, err := decodeSegment("%"); err == nil { t.Fatalf("expected decode error") } }) } type roundTripFunc func(req *http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } func makeUnsignedJWT(claims map[string]any) string { header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) payloadBytes, _ := json.Marshal(claims) payload := base64.RawURLEncoding.EncodeToString(payloadBytes) return header + "." + payload + ".sig" }