package auth import ( "context" "errors" "net/http" "strings" "testing" "time" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" log "github.com/sirupsen/logrus" logtest "github.com/sirupsen/logrus/hooks/test" ) func setupTestLoggerHook(t *testing.T) *logtest.Hook { _, hook := logtest.NewNullLogger() oldLevel := log.GetLevel() log.SetLevel(log.WarnLevel) // Deep-clone existing hooks savedHooks := make(log.LevelHooks) for lvl, hs := range log.StandardLogger().Hooks { savedHooks[lvl] = append([]log.Hook(nil), hs...) } log.AddHook(hook) t.Cleanup(func() { log.SetLevel(oldLevel) log.StandardLogger().ReplaceHooks(savedHooks) }) return hook } func TestWarnLogOnAuthUnavailable_SingleProvider(t *testing.T) { previousCooldown := quotaCooldownDisabled.Load() quotaCooldownDisabled.Store(false) t.Cleanup(func() { quotaCooldownDisabled.Store(previousCooldown) }) hook := setupTestLoggerHook(t) m := NewManager(nil, nil, nil) now := time.Now() auth1 := &Auth{ ID: "auth-cooling-1", Provider: "claude", Status: StatusActive, FileName: "claude-key-1.json", StatusMessage: "rate_limit_exceeded", Quota: QuotaState{ Exceeded: true, Reason: "rate_limit_exceeded", NextRecoverAt: now.Add(45 * time.Second), }, NextRetryAfter: now.Add(45 * time.Second), } auth2 := &Auth{ ID: "auth-cooling-2", Provider: "claude", Status: StatusActive, FileName: "claude-key-2.json", StatusMessage: "quota_exceeded", Quota: QuotaState{ Exceeded: true, Reason: "quota_exceeded", NextRecoverAt: now.Add(90 * time.Second), }, NextRetryAfter: now.Add(90 * time.Second), } reg := registry.GetGlobalRegistry() reg.RegisterClient(auth1.ID, "claude", []*registry.ModelInfo{{ID: "claude-3-5-sonnet"}}) reg.RegisterClient(auth2.ID, "claude", []*registry.ModelInfo{{ID: "claude-3-5-sonnet"}}) t.Cleanup(func() { reg.UnregisterClient(auth1.ID) reg.UnregisterClient(auth2.ID) }) if _, err := m.Register(context.Background(), auth1); err != nil { t.Fatalf("register auth1: %v", err) } if _, err := m.Register(context.Background(), auth2); err != nil { t.Fatalf("register auth2: %v", err) } exec := &mockCustomErrorExecutor{ identifier: "claude", } m.RegisterExecutor(exec) hook.Reset() req := cliproxyexecutor.Request{Model: "claude-3-5-sonnet"} opts := cliproxyexecutor.Options{} _, errExec := m.Execute(context.Background(), []string{"claude"}, req, opts) if errExec == nil { t.Fatal("expected error from Execute, got nil") } // Verify exactly one Warn line was emitted explaining the cooling auths warnCount := 0 for _, entry := range hook.AllEntries() { if entry.Level == log.WarnLevel && strings.Contains(entry.Message, "auth unavailable") { warnCount++ if !strings.Contains(entry.Message, "claude-key-1.json") || !strings.Contains(entry.Message, "rate_limit_exceeded") || !strings.Contains(entry.Message, "claude-key-2.json") || !strings.Contains(entry.Message, "quota_exceeded") || !strings.Contains(entry.Message, "remaining=") { t.Fatalf("unexpected Warn log content: %s", entry.Message) } } } if warnCount != 1 { t.Fatalf("expected exactly 1 Warn log, got %d. Logs: %#v", warnCount, hook.AllEntries()) } } func TestWarnLogOnAuthUnavailable_SessionAffinityLegacyPath(t *testing.T) { previousCooldown := quotaCooldownDisabled.Load() quotaCooldownDisabled.Store(false) t.Cleanup(func() { quotaCooldownDisabled.Store(previousCooldown) }) hook := setupTestLoggerHook(t) m := NewManager(nil, nil, nil) affinity := NewSessionAffinitySelector(&RoundRobinSelector{}) defer affinity.Stop() m.SetSelector(affinity) now := time.Now() auth1 := &Auth{ ID: "auth-legacy-cooling-1", Provider: "claude", Status: StatusActive, FileName: "claude-legacy.json", StatusMessage: "rate_limit_exceeded", Quota: QuotaState{ Exceeded: true, Reason: "rate_limit_exceeded", NextRecoverAt: now.Add(45 * time.Second), }, NextRetryAfter: now.Add(45 * time.Second), } reg := registry.GetGlobalRegistry() reg.RegisterClient(auth1.ID, "claude", []*registry.ModelInfo{{ID: "claude-3-5-sonnet"}}) t.Cleanup(func() { reg.UnregisterClient(auth1.ID) }) if _, err := m.Register(context.Background(), auth1); err != nil { t.Fatalf("register auth1: %v", err) } exec := &mockCustomErrorExecutor{ identifier: "claude", } m.RegisterExecutor(exec) hook.Reset() req := cliproxyexecutor.Request{Model: "claude-3-5-sonnet"} opts := cliproxyexecutor.Options{} _, errExec := m.Execute(context.Background(), []string{"claude"}, req, opts) if errExec == nil { t.Fatal("expected error from Execute, got nil") } warnCount := 0 for _, entry := range hook.AllEntries() { if entry.Level == log.WarnLevel && strings.Contains(entry.Message, "auth unavailable") { warnCount++ if !strings.Contains(entry.Message, "claude-legacy.json") || !strings.Contains(entry.Message, "rate_limit_exceeded") { t.Fatalf("unexpected Warn log content: %s", entry.Message) } } } if warnCount != 1 { t.Fatalf("expected exactly 1 Warn log from legacy path, got %d. Logs: %#v", warnCount, hook.AllEntries()) } } func TestWarnLogOnAuthUnavailable_MixedProviders(t *testing.T) { previousCooldown := quotaCooldownDisabled.Load() quotaCooldownDisabled.Store(false) t.Cleanup(func() { quotaCooldownDisabled.Store(previousCooldown) }) hook := setupTestLoggerHook(t) m := NewManager(nil, nil, nil) now := time.Now() auth1 := &Auth{ ID: "auth-claude-cooling", Provider: "claude", Status: StatusActive, FileName: "claude.json", StatusMessage: "rate_limit", Quota: QuotaState{ Exceeded: true, Reason: "rate_limit", NextRecoverAt: now.Add(30 * time.Second), }, NextRetryAfter: now.Add(30 * time.Second), } auth2 := &Auth{ ID: "auth-codex-cooling", Provider: "codex", Status: StatusActive, FileName: "codex.json", StatusMessage: "quota_exceeded", Quota: QuotaState{ Exceeded: true, Reason: "quota_exceeded", NextRecoverAt: now.Add(60 * time.Second), }, NextRetryAfter: now.Add(60 * time.Second), } reg := registry.GetGlobalRegistry() reg.RegisterClient(auth1.ID, "claude", []*registry.ModelInfo{{ID: "gpt-5"}}) reg.RegisterClient(auth2.ID, "codex", []*registry.ModelInfo{{ID: "gpt-5"}}) t.Cleanup(func() { reg.UnregisterClient(auth1.ID) reg.UnregisterClient(auth2.ID) }) if _, err := m.Register(context.Background(), auth1); err != nil { t.Fatalf("register auth1: %v", err) } if _, err := m.Register(context.Background(), auth2); err != nil { t.Fatalf("register auth2: %v", err) } m.RegisterExecutor(&mockCustomErrorExecutor{identifier: "claude"}) m.RegisterExecutor(&mockCustomErrorExecutor{identifier: "codex"}) hook.Reset() req := cliproxyexecutor.Request{Model: "gpt-5"} opts := cliproxyexecutor.Options{} _, errExec := m.Execute(context.Background(), []string{"claude", "codex"}, req, opts) if errExec == nil { t.Fatal("expected error from Execute, got nil") } warnCount := 0 for _, entry := range hook.AllEntries() { if entry.Level == log.WarnLevel && strings.Contains(entry.Message, "auth unavailable") { warnCount++ if !strings.Contains(entry.Message, "claude.json") || !strings.Contains(entry.Message, "codex.json") || !strings.Contains(entry.Message, "providers=claude,codex") { t.Fatalf("unexpected mixed Warn log: %s", entry.Message) } } } if warnCount != 1 { t.Fatalf("expected exactly 1 mixed Warn log, got %d. Logs: %#v", warnCount, hook.AllEntries()) } } func TestWarnLogOnUpstreamFailure_NonStream(t *testing.T) { previousCooldown := quotaCooldownDisabled.Load() quotaCooldownDisabled.Store(false) t.Cleanup(func() { quotaCooldownDisabled.Store(previousCooldown) }) hook := setupTestLoggerHook(t) m := NewManager(nil, nil, nil) auth := &Auth{ ID: "auth-test-upstream", Provider: "codex", FileName: "codex-prod.json", Status: StatusActive, Attributes: map[string]string{"priority": "10"}, } reg := registry.GetGlobalRegistry() reg.RegisterClient(auth.ID, "codex", []*registry.ModelInfo{{ID: "gpt-4o"}}) t.Cleanup(func() { reg.UnregisterClient(auth.ID) }) if _, err := m.Register(context.Background(), auth); err != nil { t.Fatalf("register auth: %v", err) } exec := &mockCustomErrorExecutor{ identifier: "codex", executeFn: func(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { time.Sleep(5 * time.Millisecond) return cliproxyexecutor.Response{}, errors.New("500 Internal Server Error: upstream timeout") }, } m.RegisterExecutor(exec) hook.Reset() req := cliproxyexecutor.Request{Model: "gpt-4o"} opts := cliproxyexecutor.Options{} _, errExec := m.Execute(context.Background(), []string{"codex"}, req, opts) if errExec == nil { t.Fatal("expected error, got nil") } foundWarn := false for _, entry := range hook.AllEntries() { if entry.Level == log.WarnLevel && strings.Contains(entry.Message, "upstream execution failed") { if strings.Contains(entry.Message, "provider=codex") && strings.Contains(entry.Message, "model=gpt-4o") && strings.Contains(entry.Message, "codex-prod.json") && strings.Contains(entry.Message, "duration=") && strings.Contains(entry.Message, "upstream timeout") { foundWarn = true break } } } if !foundWarn { t.Fatalf("expected Warn log detailing upstream failure, got logs: %#v", hook.AllEntries()) } } func TestWarnLogOnUpstreamFailure_401RefreshSuccess_DoesNotLogWarn(t *testing.T) { previousCooldown := quotaCooldownDisabled.Load() quotaCooldownDisabled.Store(false) t.Cleanup(func() { quotaCooldownDisabled.Store(previousCooldown) }) hook := setupTestLoggerHook(t) m := NewManager(nil, nil, nil) auth := &Auth{ ID: "auth-test-401-refresh", Provider: "codex", FileName: "codex-oauth.json", Status: StatusActive, Attributes: map[string]string{"auth_kind": "oauth", "priority": "10"}, Metadata: map[string]any{"access_token": "old-token", "refresh_token": "valid-refresh-token"}, } reg := registry.GetGlobalRegistry() reg.RegisterClient(auth.ID, "codex", []*registry.ModelInfo{{ID: "gpt-4o"}}) t.Cleanup(func() { reg.UnregisterClient(auth.ID) }) if _, err := m.Register(context.Background(), auth); err != nil { t.Fatalf("register auth: %v", err) } callCount := 0 exec := &mockCustomErrorExecutor{ identifier: "codex", executeFn: func(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { callCount++ if callCount == 1 { return cliproxyexecutor.Response{}, customStatusError{code: http.StatusUnauthorized, msg: "401 unauthorized"} } return cliproxyexecutor.Response{Payload: []byte(`{"ok":true}`)}, nil }, } m.RegisterExecutor(exec) hook.Reset() req := cliproxyexecutor.Request{Model: "gpt-4o"} opts := cliproxyexecutor.Options{} resp, errExec := m.Execute(context.Background(), []string{"codex"}, req, opts) if errExec != nil { t.Fatalf("unexpected error from Execute: %v", errExec) } if string(resp.Payload) != `{"ok":true}` { t.Fatalf("unexpected response payload: %s", string(resp.Payload)) } // 401 refresh was successful, so no upstream failure warning should be logged for _, entry := range hook.AllEntries() { if entry.Level == log.WarnLevel && strings.Contains(entry.Message, "upstream execution failed") { t.Fatalf("did not expect upstream failure warning when 401 refresh succeeded, got: %s", entry.Message) } } } func TestWarnLogOnUpstreamFailure_ClientCanceled_DoesNotLogWarn(t *testing.T) { previousCooldown := quotaCooldownDisabled.Load() quotaCooldownDisabled.Store(false) t.Cleanup(func() { quotaCooldownDisabled.Store(previousCooldown) }) hook := setupTestLoggerHook(t) m := NewManager(nil, nil, nil) auth := &Auth{ ID: "auth-test-canceled", Provider: "codex", FileName: "codex-prod.json", Status: StatusActive, Attributes: map[string]string{"priority": "10"}, } reg := registry.GetGlobalRegistry() reg.RegisterClient(auth.ID, "codex", []*registry.ModelInfo{{ID: "gpt-4o"}}) t.Cleanup(func() { reg.UnregisterClient(auth.ID) }) if _, err := m.Register(context.Background(), auth); err != nil { t.Fatalf("register auth: %v", err) } ctx, cancel := context.WithCancel(context.Background()) cancel() // cancel immediately exec := &mockCustomErrorExecutor{ identifier: "codex", executeFn: func(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { return cliproxyexecutor.Response{}, ctx.Err() }, } m.RegisterExecutor(exec) hook.Reset() req := cliproxyexecutor.Request{Model: "gpt-4o"} opts := cliproxyexecutor.Options{} _, _ = m.Execute(ctx, []string{"codex"}, req, opts) for _, entry := range hook.AllEntries() { if entry.Level == log.WarnLevel && strings.Contains(entry.Message, "upstream execution failed") { t.Fatalf("did not expect upstream failure warning on client cancellation, got: %s", entry.Message) } } } func TestWarnLogOnStreamUpstreamFailure(t *testing.T) { previousCooldown := quotaCooldownDisabled.Load() quotaCooldownDisabled.Store(false) t.Cleanup(func() { quotaCooldownDisabled.Store(previousCooldown) }) hook := setupTestLoggerHook(t) m := NewManager(nil, nil, nil) auth := &Auth{ ID: "auth-test-stream-upstream", Provider: "claude", FileName: "claude-stream.json", Status: StatusActive, Attributes: map[string]string{"priority": "10"}, } reg := registry.GetGlobalRegistry() reg.RegisterClient(auth.ID, "claude", []*registry.ModelInfo{{ID: "claude-sonnet-4"}}) t.Cleanup(func() { reg.UnregisterClient(auth.ID) }) if _, err := m.Register(context.Background(), auth); err != nil { t.Fatalf("register auth: %v", err) } exec := &mockStreamErrorExecutor{ identifier: "claude", executeStreamFn: func(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { time.Sleep(5 * time.Millisecond) return nil, errors.New("502 Bad Gateway: connection dropped") }, } m.RegisterExecutor(exec) hook.Reset() req := cliproxyexecutor.Request{Model: "claude-sonnet-4"} opts := cliproxyexecutor.Options{} _, errStream := m.ExecuteStream(context.Background(), []string{"claude"}, req, opts) if errStream == nil { t.Fatal("expected error from ExecuteStream, got nil") } foundWarn := false for _, entry := range hook.AllEntries() { if entry.Level == log.WarnLevel && strings.Contains(entry.Message, "upstream execution failed") { if strings.Contains(entry.Message, "provider=claude") && strings.Contains(entry.Message, "model=claude-sonnet-4") && strings.Contains(entry.Message, "claude-stream.json") && strings.Contains(entry.Message, "duration=") && strings.Contains(entry.Message, "connection dropped") { foundWarn = true break } } } if !foundWarn { t.Fatalf("expected Warn log detailing stream upstream failure, got logs: %#v", hook.AllEntries()) } } func TestWarnLogOnStreamBootstrapFailure(t *testing.T) { previousCooldown := quotaCooldownDisabled.Load() quotaCooldownDisabled.Store(false) t.Cleanup(func() { quotaCooldownDisabled.Store(previousCooldown) }) hook := setupTestLoggerHook(t) m := NewManager(nil, nil, nil) auth := &Auth{ ID: "auth-test-bootstrap-upstream", Provider: "claude", FileName: "claude-bootstrap.json", Status: StatusActive, Attributes: map[string]string{"priority": "10"}, } reg := registry.GetGlobalRegistry() reg.RegisterClient(auth.ID, "claude", []*registry.ModelInfo{{ID: "claude-sonnet-4"}}) t.Cleanup(func() { reg.UnregisterClient(auth.ID) }) if _, err := m.Register(context.Background(), auth); err != nil { t.Fatalf("register auth: %v", err) } exec := &mockStreamErrorExecutor{ identifier: "claude", executeStreamFn: func(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { ch := make(chan cliproxyexecutor.StreamChunk, 1) ch <- cliproxyexecutor.StreamChunk{Err: errors.New("504 Gateway Timeout: ttfb timeout")} close(ch) return &cliproxyexecutor.StreamResult{Chunks: ch}, nil }, } m.RegisterExecutor(exec) hook.Reset() req := cliproxyexecutor.Request{Model: "claude-sonnet-4"} opts := cliproxyexecutor.Options{} res, errStream := m.ExecuteStream(context.Background(), []string{"claude"}, req, opts) if errStream != nil { t.Fatalf("unexpected ExecuteStream bootstrap error: %v", errStream) } if res == nil || res.Chunks == nil { t.Fatal("expected non-nil StreamResult") } firstChunk := <-res.Chunks if firstChunk.Err == nil { t.Fatal("expected bootstrap chunk error, got nil") } foundWarn := false for _, entry := range hook.AllEntries() { if entry.Level == log.WarnLevel && strings.Contains(entry.Message, "upstream execution failed") { if strings.Contains(entry.Message, "provider=claude") && strings.Contains(entry.Message, "model=claude-sonnet-4") && strings.Contains(entry.Message, "claude-bootstrap.json") && strings.Contains(entry.Message, "ttfb timeout") { foundWarn = true break } } } if !foundWarn { t.Fatalf("expected Warn log detailing stream bootstrap failure, got logs: %#v", hook.AllEntries()) } } type mockStreamErrorExecutor struct { identifier string executeStreamFn func(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) } func (e *mockStreamErrorExecutor) Identifier() string { if e.identifier != "" { return e.identifier } return "mock-stream" } func (e *mockStreamErrorExecutor) Execute(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { return cliproxyexecutor.Response{}, errors.New("not implemented") } func (e *mockStreamErrorExecutor) ExecuteStream(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { if e.executeStreamFn != nil { return e.executeStreamFn(ctx, auth, req, opts) } return nil, errors.New("not implemented") } func (e *mockStreamErrorExecutor) Refresh(ctx context.Context, auth *Auth) (*Auth, error) { return auth, nil } func (e *mockStreamErrorExecutor) CountTokens(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { return cliproxyexecutor.Response{}, errors.New("not implemented") } func (e *mockStreamErrorExecutor) HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error) { return nil, errors.New("not implemented") }