Files
zero/internal/config/credentials_test.go
ba99fa8d48 fix(provider): stop dropping custom no-auth providers on restart (#558)
* fix(provider): stop dropping custom no-auth providers on restart

Custom OpenAI/Anthropic-compatible catalog entries hardcode
RequiresAuth as a template default for the setup wizard, not a fact
about the user's actual endpoint. Leaving the credential step blank
(e.g. a local llama.cpp server) still stamped the saved profile with
a guessed OPENAI_API_KEY/ANTHROPIC_API_KEY env var, and that same
static RequiresAuth flag then made the profile look like it was
missing a required credential on the next run, silently filtering it
out of /model.

Add a Custom flag to the catalog descriptor so both the wizard
(write side) and setupMissingCredentialEnv (read side) judge these
profiles by their own explicit APIKeyEnv instead of the catalog's
static default.

Fixes #555

Co-Authored-By: Claude Sonnet 5 <[email protected]>

Co-authored-by: Pierre Bruno <[email protected]>

* fix(provider): self-heal legacy custom profiles stamped by the old wizard bug

CodeRabbit flagged that profiles saved before this fix could already
carry the phantom APIKeyEnv the old wizard stamped on every custom
provider (e.g. OPENAI_API_KEY), and the new custom-branch logic would
keep treating any non-empty APIKeyEnv as an explicit requirement —
leaving already-affected users' providers filtered out of /model even
after upgrading.

Treat an APIKeyEnv that exactly matches the catalog's own guessed
default as indistinguishable from unset, so already-broken saved
profiles self-heal without a config.json migration. A deliberately
different env var name (e.g. via --api-key-env) still means auth is
required.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

Co-authored-by: Pierre Bruno <[email protected]>

* fix(provider): centralize custom-endpoint credential logic, fix two more surfaces

Copilot's review of the earlier fix found the same RequiresAuth-as-
authoritative bug in two more places that decide whether a saved
provider needs a credential:

- validateProviderRuntimeReady (zero providers check) would hard-error
  a no-auth custom endpoint with "requires API key".
- providerWizardCredentialLabel and providerCredentialRequired (/providers
  status, wizard summary) would still claim/warn that one was needed.

Added config.ProviderProfile.MissingCredentialEnv as the single source
of truth for this decision (custom-endpoint self-heal included), and
had setupMissingCredentialEnv and validateProviderRuntimeReady delegate
to it. providerWizardCredentialLabel and providerCredentialRequired
take the same Custom-aware fix directly, since their non-catalog
fallback path uses runtime-resolved provider-kind metadata that
shouldn't be collapsed into the canonical helper.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

Co-authored-by: Pierre Bruno <[email protected]>

* fix(ci): request pinned Go toolchain directly

Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
2026-07-08 08:54:39 +08:00

320 lines
10 KiB
Go

package config
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
type fakeKeyGetter struct {
keys map[string]string
err error
}
func (f fakeKeyGetter) Get(provider string) (string, bool, error) {
if f.err != nil {
return "", false, f.err
}
v, ok := f.keys[provider]
return v, ok, nil
}
// fakeKeySetter records Set calls; setErr (when non-nil) makes Set fail.
type fakeKeySetter struct {
keys map[string]string
setErr error
}
func (f *fakeKeySetter) Set(provider, key string) error {
if f.setErr != nil {
return f.setErr
}
f.keys[provider] = key
return nil
}
func TestApplyStoredAPIKey(t *testing.T) {
store := fakeKeyGetter{keys: map[string]string{"openai": "sk-stored"}}
// Stored marker + empty key => filled from the store.
got := ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true}, store)
if got.APIKey != "sk-stored" {
t.Fatalf("expected stored key to fill empty APIKey, got %q", got.APIKey)
}
// No APIKeyStored marker => store is NOT consulted (don't reactivate a stale key).
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai"}, store)
if got.APIKey != "" {
t.Fatalf("expected no load without APIKeyStored, got %q", got.APIKey)
}
// Inline key present => store is NOT consulted (inline wins).
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true, APIKey: "sk-inline"}, store)
if got.APIKey != "sk-inline" {
t.Fatalf("inline key must win, got %q", got.APIKey)
}
// Marker set but no stored key for this provider => unchanged (empty).
got = ApplyStoredAPIKey(ProviderProfile{Name: "anthropic", APIKeyStored: true}, store)
if got.APIKey != "" {
t.Fatalf("expected no key for unstored provider, got %q", got.APIKey)
}
// Nil store => unchanged.
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true}, nil)
if got.APIKey != "" {
t.Fatalf("nil store must leave profile unchanged, got %q", got.APIKey)
}
// Empty name => unchanged (don't query the store).
got = ApplyStoredAPIKey(ProviderProfile{APIKeyStored: true}, store)
if got.APIKey != "" {
t.Fatalf("empty name must not be filled, got %q", got.APIKey)
}
}
func TestMigratePlaintextProviderKeys(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
cfgJSON := `{"activeProvider":"openai","providers":[` +
`{"name":"openai","apiKey":"sk-PLAINTEXT","model":"gpt"},` +
`{"name":"local","baseURL":"http://localhost"},` +
`{"name":"acme","apiKeyEnv":"ACME_KEY"}]}`
if err := os.WriteFile(path, []byte(cfgJSON), 0o600); err != nil {
t.Fatal(err)
}
store := &fakeKeySetter{keys: map[string]string{}}
n, err := MigratePlaintextProviderKeys(path, store)
if err != nil || n != 1 {
t.Fatalf("migrate = %d,%v; want 1,nil", n, err)
}
if store.keys["openai"] != "sk-PLAINTEXT" {
t.Fatalf("key not moved to store: %v", store.keys)
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), "sk-PLAINTEXT") {
t.Fatalf("plaintext key still in config.json:\n%s", raw)
}
if !strings.Contains(string(raw), "apiKeyStored") {
t.Fatalf("apiKeyStored marker not written:\n%s", raw)
}
// Idempotent: a second run migrates nothing.
if n2, _ := MigratePlaintextProviderKeys(path, store); n2 != 0 {
t.Fatalf("second migrate = %d, want 0", n2)
}
}
func TestMigrateLeavesKeyWhenStoreSetFails(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"providers":[{"name":"openai","apiKey":"sk-KEEP"}]}`), 0o600); err != nil {
t.Fatal(err)
}
store := &fakeKeySetter{keys: map[string]string{}, setErr: errors.New("keychain locked")}
n, err := MigratePlaintextProviderKeys(path, store)
if err != nil || n != 0 {
t.Fatalf("migrate with failing store = %d,%v; want 0,nil", n, err)
}
raw, _ := os.ReadFile(path)
if !strings.Contains(string(raw), "sk-KEEP") {
t.Fatalf("a failed Set must not strand the key; config.json:\n%s", raw)
}
}
func TestClearProviderKeyStored(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"providers":[{"name":"openai","apiKeyStored":true},{"name":"other","apiKeyStored":true}]}`), 0o600); err != nil {
t.Fatal(err)
}
cleared, err := ClearProviderKeyStored(path, "openai")
if err != nil || !cleared {
t.Fatalf("clear = %v,%v; want true,nil", cleared, err)
}
raw, _ := os.ReadFile(path)
// openai's marker is gone; other's remains.
var cfg FileConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatal(err)
}
for _, p := range cfg.Providers {
if p.Name == "openai" && p.APIKeyStored {
t.Fatal("openai marker should be cleared")
}
if p.Name == "other" && !p.APIKeyStored {
t.Fatal("other marker should be untouched")
}
}
// Idempotent: clearing again reports no change.
if cleared, _ := ClearProviderKeyStored(path, "openai"); cleared {
t.Fatal("second clear should report no change")
}
// Unknown provider: no change.
if cleared, _ := ClearProviderKeyStored(path, "nope"); cleared {
t.Fatal("unknown provider should report no change")
}
}
func TestProviderProfileAPIKeyStoredRoundTrips(t *testing.T) {
// The apiKeyStored marker survives JSON decode (custom UnmarshalJSON).
var p ProviderProfile
if err := p.UnmarshalJSON([]byte(`{"name":"openai","apiKeyStored":true}`)); err != nil {
t.Fatal(err)
}
if !p.APIKeyStored {
t.Fatal("expected apiKeyStored=true to decode")
}
if p.APIKey != "" {
t.Fatalf("no inline key expected, got %q", p.APIKey)
}
}
// OAuthLoginCandidates always offers the profile name, adds the catalog ID as a
// fallback ONLY when the profile has no effective own credential, and dedupes
// case-sensitively (the OAuth store is a case-sensitive map).
func TestOAuthLoginCandidates(t *testing.T) {
cases := []struct {
name string
profile ProviderProfile
want []string
}{
{
name: "renamed keyless profile falls back to catalog id",
profile: ProviderProfile{Name: "codex", CatalogID: "chatgpt"},
want: []string{"codex", "chatgpt"},
},
{
name: "case-variant name keeps distinct catalog-id candidate",
profile: ProviderProfile{Name: "ChatGPT", CatalogID: "chatgpt"},
want: []string{"ChatGPT", "chatgpt"},
},
{
name: "exact-duplicate name and catalog id collapse",
profile: ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"},
want: []string{"chatgpt"},
},
{
// A configured key must block ALL candidates (name included): a login
// under the profile's own name would otherwise erase the key too.
name: "own inline key blocks every candidate",
profile: ProviderProfile{Name: "anthropic-work", CatalogID: "anthropic", APIKey: "sk-work"},
want: nil,
},
{
name: "own auth header blocks every candidate",
profile: ProviderProfile{Name: "acme", CatalogID: "anthropic", AuthHeaderValue: "Bearer x"},
want: nil,
},
{
// APIKeyStored means key-auth even if the key isn't loaded here (e.g. a
// transiently unreadable keyring): don't silently borrow an OAuth login.
name: "stored-key profile blocks every candidate",
profile: ProviderProfile{Name: "openai-stored", CatalogID: "openai", APIKeyStored: true},
want: nil,
},
{
// APIKeyEnv is NOT a configured credential for gating: the env var may be
// unset while the profile relies on an OAuth login, so candidates stand.
name: "env-only profile still yields candidates",
profile: ProviderProfile{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
want: []string{"xai"},
},
{
name: "empty profile yields no candidates",
profile: ProviderProfile{},
want: nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := c.profile.OAuthLoginCandidates()
if strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Fatalf("OAuthLoginCandidates() = %#v, want %#v", got, c.want)
}
})
}
}
func TestProviderProfileMissingCredentialEnv(t *testing.T) {
cases := []struct {
name string
profile ProviderProfile
wantEnv string
want bool
}{
{
name: "catalog provider requiring auth",
profile: ProviderProfile{Name: "groq", CatalogID: "groq"},
wantEnv: "GROQ_API_KEY",
want: true,
},
{
name: "local catalog provider",
profile: ProviderProfile{Name: "local", CatalogID: "ollama"},
want: false,
},
{
name: "credential resolved via inline key",
profile: ProviderProfile{Name: "openai", ProviderKind: ProviderKindOpenAI, APIKey: "sk-test"},
want: false,
},
{
// issue #555: a custom endpoint left with no credential means "no
// auth needed" (e.g. a local llama.cpp server), not "missing".
name: "custom openai compatible with no credential configured",
profile: ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
},
want: false,
},
{
name: "custom openai compatible with explicit non-default api key env",
profile: ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
APIKeyEnv: "LLAMA_CPP_API_KEY",
},
wantEnv: "LLAMA_CPP_API_KEY",
want: true,
},
{
// Self-heal: a profile stamped by the pre-fix wizard with the
// catalog's own guessed default is indistinguishable from that bug
// and must not stay filtered out after upgrading.
name: "custom openai compatible with stale legacy default env",
profile: ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
APIKeyEnv: "OPENAI_API_KEY",
},
want: false,
},
{
name: "openai compatible without catalog falls back to provider kind",
profile: ProviderProfile{Name: "custom", ProviderKind: ProviderKindOpenAICompatible},
wantEnv: "OPENAI_API_KEY",
want: true,
},
{
name: "legacy Provider string field fallback",
profile: ProviderProfile{Name: "legacy", Provider: "anthropic"},
wantEnv: "ANTHROPIC_API_KEY",
want: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotEnv, got := c.profile.MissingCredentialEnv()
if got != c.want || gotEnv != c.wantEnv {
t.Fatalf("MissingCredentialEnv() = (%q, %v), want (%q, %v)", gotEnv, got, c.wantEnv, c.want)
}
})
}
}