package provider import ( "context" "testing" "time" ) func TestEnvAPIKey(t *testing.T) { t.Setenv("ANTHROPIC_OAUTH_TOKEN", "") t.Setenv("ANTHROPIC_API_KEY", "sk-ant-env") if got := envAPIKey("anthropic"); got != "sk-ant-env" { t.Errorf("env key = %q, want sk-ant-env", got) } // Unknown provider uses generic _API_KEY fallback. t.Setenv("FOOBAR_API_KEY", "sk-foobar") if got := envAPIKey("foobar"); got != "sk-foobar" { t.Errorf("generic env key = %q, want sk-foobar", got) } if got := envAPIKey("nonesuch"); got != "" { t.Errorf("missing env key = %q, want empty", got) } } // TestEnvAPIKeyFromRegistry verifies API-key resolution derives from the // provider registry (single source of truth) across a representative set of // providers, that Anthropic's OAuth token takes precedence over its API key, // and that an unknown provider hits the generic _API_KEY fallback. func TestEnvAPIKeyFromRegistry(t *testing.T) { cases := []struct { provider string envVar string value string }{ {"deepseek", "DEEPSEEK_API_KEY", "sk-deepseek"}, {"groq", "GROQ_API_KEY", "sk-groq"}, {"zai", "ZAI_API_KEY", "sk-zai"}, {"moonshotai-cn", "MOONSHOT_API_KEY", "sk-moonshot-cn"}, {"xiaomi-token-plan-ams", "XIAOMI_TOKEN_PLAN_AMS_API_KEY", "sk-xiaomi-ams"}, } for _, tc := range cases { t.Run(tc.provider, func(t *testing.T) { t.Setenv(tc.envVar, tc.value) if got := envAPIKey(tc.provider); got != tc.value { t.Errorf("envAPIKey(%q) = %q, want %q", tc.provider, got, tc.value) } }) } // Anthropic: OAuth token wins over API key (registry ordering). t.Run("anthropic-oauth-first", func(t *testing.T) { t.Setenv("ANTHROPIC_OAUTH_TOKEN", "oauth-tok") t.Setenv("ANTHROPIC_API_KEY", "sk-ant") if got := envAPIKey("anthropic"); got != "oauth-tok" { t.Errorf("anthropic = %q, want oauth-tok (OAuth precedence)", got) } // With OAuth unset, the API key resolves. t.Setenv("ANTHROPIC_OAUTH_TOKEN", "") if got := envAPIKey("anthropic"); got != "sk-ant" { t.Errorf("anthropic (no oauth) = %q, want sk-ant", got) } }) // Unknown provider falls back to the generic convention. t.Run("unknown-generic-fallback", func(t *testing.T) { t.Setenv("MADEUP_PROVIDER_API_KEY", "sk-generic") if got := envAPIKey("madeup-provider"); got != "" { // Hyphenated names uppercase to MADEUP-PROVIDER_API_KEY, not a match; // verify the true generic form resolves for an underscore-friendly name. t.Logf("hyphenated generic = %q", got) } t.Setenv("MADEUPPROVIDER_API_KEY", "sk-generic2") if got := envAPIKey("madeupprovider"); got != "sk-generic2" { t.Errorf("generic fallback = %q, want sk-generic2", got) } }) } func TestLoadAPIKeyConfig(t *testing.T) { cfg, err := LoadAPIKeyConfig([]byte(`{"keys":{"openai":"sk-openai-cfg"}}`)) if err != nil { t.Fatalf("load: %v", err) } if cfg.Keys["openai"] != "sk-openai-cfg" { t.Errorf("config key = %q", cfg.Keys["openai"]) } if _, err := LoadAPIKeyConfig([]byte(`not json`)); err == nil { t.Fatal("bad JSON must error") } } func TestLoadAPIKeyConfigFileMissing(t *testing.T) { cfg, err := LoadAPIKeyConfigFile("/no/such/path/keys.json") if err != nil { t.Fatalf("missing file must not error: %v", err) } if len(cfg.Keys) != 0 { t.Errorf("missing file must yield empty keys, got %v", cfg.Keys) } } // TestCredentialStoreResolutionOrder verifies OAuth > env > config precedence. func TestCredentialStoreResolutionOrder(t *testing.T) { cfg, _ := LoadAPIKeyConfig([]byte(`{"keys":{"anthropic":"sk-cfg","openai":"sk-openai-cfg"}}`)) store := NewCredentialStore(cfg) // Neutralize any ambient keys so config-only resolution is deterministic. t.Setenv("OPENAI_API_KEY", "") t.Setenv("ANTHROPIC_API_KEY", "") t.Setenv("CLAUDE_API_KEY", "") // Config-only provider resolves from config. if got := store.GetAPIKey(context.Background(), "openai"); got != "sk-openai-cfg" { t.Errorf("openai (config) = %q, want sk-openai-cfg", got) } // Env overrides config. t.Setenv("ANTHROPIC_API_KEY", "sk-env") if got := store.GetAPIKey(context.Background(), "anthropic"); got != "sk-env" { t.Errorf("anthropic (env>config) = %q, want sk-env", got) } // OAuth overrides env + config. store.RegisterOAuth("anthropic", NewTokenSource( OAuthToken{AccessToken: "oauth-token", Expiry: time.Now().Add(time.Hour)}, nil)) if got := store.GetAPIKey(context.Background(), "anthropic"); got != "oauth-token" { t.Errorf("anthropic (oauth>env) = %q, want oauth-token", got) } // Unknown provider → empty. if got := store.GetAPIKey(context.Background(), "ghost"); got != "" { t.Errorf("ghost = %q, want empty", got) } } // TestCredentialStoreOverride verifies an explicit --api-key override wins over // env and config, but not over a live OAuth token, and that an empty override // is ignored (so a bare flag does not clobber env/config). func TestCredentialStoreOverride(t *testing.T) { cfg, _ := LoadAPIKeyConfig([]byte(`{"keys":{"openai":"sk-openai-cfg"}}`)) store := NewCredentialStore(cfg) t.Setenv("OPENAI_API_KEY", "sk-openai-env") // Empty override is a no-op: env still wins over config. store.SetOverride("openai", "") if got := store.GetAPIKey(context.Background(), "openai"); got != "sk-openai-env" { t.Errorf("empty override should not clobber env, got %q", got) } // Non-empty override wins over env and config. store.SetOverride("openai", "sk-flag") if got := store.GetAPIKey(context.Background(), "openai"); got != "sk-flag" { t.Errorf("override should win over env/config, got %q", got) } // OAuth still wins over an override. store.RegisterOAuth("openai", NewTokenSource( OAuthToken{AccessToken: "oauth-token", Expiry: time.Now().Add(time.Hour)}, nil)) if got := store.GetAPIKey(context.Background(), "openai"); got != "oauth-token" { t.Errorf("oauth should win over override, got %q", got) } } // TestTokenSourceRefresh verifies an expired token triggers a refresh returning // a new token. func TestTokenSourceRefresh(t *testing.T) { now := time.Now() refreshCount := 0 src := NewTokenSource( OAuthToken{AccessToken: "old", RefreshToken: "refresh-1", Expiry: now.Add(-time.Minute)}, func(ctx context.Context, rt string) (OAuthToken, error) { refreshCount++ if rt != "refresh-1" { t.Errorf("refresh token = %q, want refresh-1", rt) } return OAuthToken{AccessToken: "new", RefreshToken: "refresh-2", Expiry: now.Add(time.Hour)}, nil }, ) src.Now = func() time.Time { return now } tok, err := src.Token(context.Background()) if err != nil { t.Fatalf("token: %v", err) } if tok != "new" { t.Errorf("token = %q, want new (refreshed)", tok) } if refreshCount != 1 { t.Errorf("refresh count = %d, want 1", refreshCount) } // Second call within validity does not refresh again. if _, err := src.Token(context.Background()); err != nil { t.Fatalf("token 2: %v", err) } if refreshCount != 1 { t.Errorf("refresh count after valid reuse = %d, want 1", refreshCount) } } func TestTokenSourceNoRefreshFunc(t *testing.T) { now := time.Now() // Expired token with no Refresh func → error. src := NewTokenSource(OAuthToken{AccessToken: "old", Expiry: now.Add(-time.Minute)}, nil) src.Now = func() time.Time { return now } if _, err := src.Token(context.Background()); err == nil { t.Fatal("expired token without refresh must error") } // Static token (zero expiry) never expires. static := NewTokenSource(OAuthToken{AccessToken: "static"}, nil) tok, err := static.Token(context.Background()) if err != nil || tok != "static" { t.Errorf("static token = %q, err = %v", tok, err) } } func TestTokenSourceRefreshError(t *testing.T) { now := time.Now() src := NewTokenSource( OAuthToken{AccessToken: "old", Expiry: now.Add(-time.Minute)}, func(ctx context.Context, rt string) (OAuthToken, error) { return OAuthToken{}, context.DeadlineExceeded }, ) src.Now = func() time.Time { return now } _, err := src.Token(context.Background()) if err == nil { t.Fatal("refresh error must propagate") } // Error must not leak the (empty) token but should mention refresh. if got := err.Error(); got == "" { t.Error("expected non-empty error") } } // TestCredentialStoreOAuthRefreshFallback verifies a failing OAuth refresh // falls back to env/config rather than returning empty when a static key exists. func TestCredentialStoreOAuthRefreshFallback(t *testing.T) { cfg, _ := LoadAPIKeyConfig([]byte(`{"keys":{"anthropic":"sk-cfg-fallback"}}`)) store := NewCredentialStore(cfg) now := time.Now() src := NewTokenSource( OAuthToken{AccessToken: "old", Expiry: now.Add(-time.Minute)}, func(ctx context.Context, rt string) (OAuthToken, error) { return OAuthToken{}, context.DeadlineExceeded }, ) src.Now = func() time.Time { return now } store.RegisterOAuth("anthropic", src) if got := store.GetAPIKey(context.Background(), "anthropic"); got != "sk-cfg-fallback" { t.Errorf("refresh-failed fallback = %q, want sk-cfg-fallback", got) } }