first commit
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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 <PROVIDER>_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 <PROVIDER>_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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user