init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,442 @@
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"
}