Files
zero/internal/cli/setup_test.go
PierrunoYTandClaude Opus 5.5 13e2596b2e chore: move repository references to Twigpine/zero (#1105)
Update the Go module path, imports, install scripts, docs, workflows,
packaging and action defaults from Gitlawb/zero to Twigpine/zero. The npm
scope (already @twigpine/zero on main, with its @gitlawb/zero migration
notes), LICENSE, action author, the OpenGateway provider and historical
CHANGELOG links are unchanged.

The X-AIMLAPI-Integration-Repo header keeps Gitlawb/zero: it is what
AIMLAPI sees for attribution, so changing it needs a maintainer's yes.
docs/INSTALL.md keeps `go install github.com/Gitlawb/zero/...`, because
every tagged release so far declares that module path. The Homebrew
formula comment names the Twigpine/homebrew-tap tap.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 07:13:21 +08:00

381 lines
13 KiB
Go

package cli
import (
"context"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/providercatalog"
"github.com/Twigpine/zero/internal/providerhealth"
"github.com/Twigpine/zero/internal/tui"
)
func TestSetupMissingCredentialEnv(t *testing.T) {
tests := []struct {
name string
profile config.ProviderProfile
wantEnv string
want bool
}{
{
name: "catalog provider",
profile: config.ProviderProfile{
Name: "groq",
CatalogID: "groq",
},
wantEnv: "GROQ_API_KEY",
want: true,
},
{
name: "openai compatible without catalog",
profile: config.ProviderProfile{
Name: "custom",
ProviderKind: config.ProviderKindOpenAICompatible,
},
wantEnv: "OPENAI_API_KEY",
want: true,
},
{
name: "local provider",
profile: config.ProviderProfile{
Name: "local",
CatalogID: "ollama",
},
want: false,
},
{
name: "ollama cloud provider",
profile: config.ProviderProfile{
Name: "ollama-cloud",
CatalogID: "ollama-cloud",
},
wantEnv: "OLLAMA_API_KEY",
want: true,
},
{
name: "credential resolved",
profile: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
APIKey: "sk-test",
},
want: false,
},
{
// Regression for issue #555: a custom OpenAI-compatible profile saved
// with no credential (e.g. a local llama.cpp server with no auth) must
// not be reported as missing one, or it gets filtered out of /model on
// the next run even though it was configured exactly as intended.
name: "custom openai compatible with no credential configured",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
},
want: false,
},
{
name: "custom openai compatible with explicit api key env",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
APIKeyEnv: "LLAMA_CPP_API_KEY",
},
wantEnv: "LLAMA_CPP_API_KEY",
want: true,
},
{
// Self-heal regression: profiles saved by the pre-fix wizard were
// stamped with the catalog's own guessed default (OPENAI_API_KEY)
// even when the endpoint needed no auth. That stale value must be
// treated the same as unset so already-broken saved profiles start
// working again without a config.json rewrite.
name: "custom openai compatible with stale legacy default env",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
APIKeyEnv: "OPENAI_API_KEY",
},
want: false,
},
{
name: "custom anthropic compatible with no credential configured",
profile: config.ProviderProfile{
Name: "local-proxy",
CatalogID: "custom-anthropic-compatible",
BaseURL: "http://localhost:9000/anthropic",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotEnv, got := setupMissingCredentialEnv(tt.profile)
if got != tt.want || gotEnv != tt.wantEnv {
t.Fatalf("setupMissingCredentialEnv() = (%q, %v), want (%q, %v)", gotEnv, got, tt.wantEnv, tt.want)
}
})
}
}
func TestSetupProviderOptionsUseRuntimeSupportedCatalog(t *testing.T) {
options := setupProviderOptions()
got := make([]string, 0, len(options))
for _, option := range options {
got = append(got, option.ID)
}
want := make([]string, 0)
for _, descriptor := range providercatalog.All() {
if providercatalog.RuntimeSupported(descriptor) {
want = append(want, descriptor.ID)
}
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("setupProviderOptions IDs = %#v, want runtime-supported catalog IDs %#v", got, want)
}
for _, excluded := range []string{"bedrock", "vertex"} {
for _, id := range got {
if id == excluded {
t.Fatalf("setupProviderOptions included unsupported provider %q in %#v", excluded, got)
}
}
}
}
func TestSaveSetupProviderStoresPastedAPIKey(t *testing.T) {
// Use the encrypted-file backend in the temp config dir so the test never
// touches the real OS keychain.
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
result, err := saveSetupProvider(appDeps{
userConfigPath: func() (string, error) {
return configPath, nil
},
}, tui.SetupSelection{
CatalogID: "ollama-cloud",
Model: "qwen3-coder:480b",
APIKey: "sk-pasted-secret",
}, setupSaveOptions{})
if err != nil {
t.Fatalf("saveSetupProvider() error = %v", err)
}
if result.Provider.APIKey != "sk-pasted-secret" {
t.Fatalf("Provider.APIKey = %q, want pasted key", result.Provider.APIKey)
}
if result.Provider.APIKeyEnv != "" {
t.Fatalf("Provider.APIKeyEnv = %q, want empty when API key is pasted", result.Provider.APIKeyEnv)
}
cfg := readFileConfig(t, configPath)
if cfg.ActiveProvider != "ollama-cloud" {
t.Fatalf("ActiveProvider = %q, want ollama-cloud", cfg.ActiveProvider)
}
if len(cfg.Providers) != 1 {
t.Fatalf("Providers = %#v, want one provider", cfg.Providers)
}
// The capture flip: config.json must NOT hold the cleartext key — it carries the
// APIKeyStored marker, and the secret lives in the co-located credential store.
if cfg.Providers[0].APIKey != "" || cfg.Providers[0].APIKeyEnv != "" {
t.Fatalf("config must not persist the key: APIKey %q APIKeyEnv %q", cfg.Providers[0].APIKey, cfg.Providers[0].APIKeyEnv)
}
if !cfg.Providers[0].APIKeyStored {
t.Fatal("expected APIKeyStored marker in config")
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
t.Fatal(err)
}
if key, ok, _ := store.Get("ollama-cloud"); !ok || key != "sk-pasted-secret" {
t.Fatalf("stored key = %q,%v; want sk-pasted-secret in the credential store", key, ok)
}
}
func TestSaveSetupProviderAimlapiEnvReferenceAndOverrideHeaders(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
result, err := saveSetupProvider(appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
}, tui.SetupSelection{
CatalogID: "aimlapi",
BaseURL: "https://staging.example/v1",
Model: "anthropic/claude-sonnet-5",
}, setupSaveOptions{})
if err != nil {
t.Fatalf("saveSetupProvider() error = %v", err)
}
if result.Provider.APIKey != "" || result.Provider.APIKeyEnv != "AIMLAPI_API_KEY" {
t.Fatalf("runtime profile did not retain AIMLAPI_API_KEY reference: %+v", result.Provider)
}
if len(result.Provider.CustomHeaders) != 0 {
t.Fatalf("catalog headers leaked to first-run override: %#v", result.Provider.CustomHeaders)
}
persisted := readFileConfig(t, configPath).Providers[0]
if persisted.APIKey != "" || persisted.APIKeyEnv != "AIMLAPI_API_KEY" || persisted.APIKeyStored {
t.Fatalf("persisted credential source changed: %+v", persisted)
}
if len(persisted.CustomHeaders) != 0 {
t.Fatalf("persisted override contains catalog headers: %#v", persisted.CustomHeaders)
}
}
func TestSaveSetupProviderAimlapiUsesResolvedPartnerOverride(t *testing.T) {
t.Setenv("AIMLAPI_PARTNER_ID", "part_override")
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
result, err := saveSetupProvider(appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
}, tui.SetupSelection{
CatalogID: "aimlapi",
BaseURL: "https://api.aimlapi.com/v1",
Model: "anthropic/claude-sonnet-5",
}, setupSaveOptions{})
if err != nil {
t.Fatalf("saveSetupProvider() error = %v", err)
}
if got := result.Provider.CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_override" {
t.Fatalf("runtime partner header = %q, want part_override", got)
}
if got := readFileConfig(t, configPath).Providers[0].CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_override" {
t.Fatalf("persisted partner header = %q, want part_override", got)
}
}
func TestSaveSetupProviderStoresCustomEndpointSelection(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
result, err := saveSetupProvider(appDeps{
userConfigPath: func() (string, error) {
return configPath, nil
},
}, tui.SetupSelection{
CatalogID: "custom-openai-compatible",
Name: "minimax",
BaseURL: "https://api.minimax.io/v1",
Model: "MiniMax-M3",
}, setupSaveOptions{})
if err != nil {
t.Fatalf("saveSetupProvider() error = %v", err)
}
if result.Provider.Name != "minimax" {
t.Fatalf("Provider.Name = %q, want minimax", result.Provider.Name)
}
if result.Provider.BaseURL != "https://api.minimax.io/v1" {
t.Fatalf("Provider.BaseURL = %q, want custom endpoint", result.Provider.BaseURL)
}
if result.Provider.Model != "MiniMax-M3" {
t.Fatalf("Provider.Model = %q, want typed model", result.Provider.Model)
}
cfg := readFileConfig(t, configPath)
if cfg.ActiveProvider != "minimax" {
t.Fatalf("ActiveProvider = %q, want minimax", cfg.ActiveProvider)
}
if len(cfg.Providers) != 1 {
t.Fatalf("Providers = %#v, want one provider", cfg.Providers)
}
profile := cfg.Providers[0]
if profile.Name != "minimax" || profile.CatalogID != "custom-openai-compatible" {
t.Fatalf("stored provider identity = %#v, want minimax custom-openai-compatible", profile)
}
if profile.BaseURL != "https://api.minimax.io/v1" || profile.Model != "MiniMax-M3" {
t.Fatalf("stored provider endpoint/model = %#v", profile)
}
}
func TestSaveSetupProviderCLIOptionsOverrideCustomEndpointSelection(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
result, err := saveSetupProvider(appDeps{
userConfigPath: func() (string, error) {
return configPath, nil
},
}, tui.SetupSelection{
CatalogID: "custom-openai-compatible",
Name: "selection-name",
BaseURL: "https://selection.example/v1",
Model: "selection-model",
}, setupSaveOptions{
name: "cli-name",
baseURL: "https://cli.example/v1",
})
if err != nil {
t.Fatalf("saveSetupProvider() error = %v", err)
}
if result.Provider.Name != "cli-name" {
t.Fatalf("Provider.Name = %q, want CLI name", result.Provider.Name)
}
if result.Provider.BaseURL != "https://cli.example/v1" {
t.Fatalf("Provider.BaseURL = %q, want CLI endpoint", result.Provider.BaseURL)
}
if result.Provider.Model != "selection-model" {
t.Fatalf("Provider.Model = %q, want selection model", result.Provider.Model)
}
cfg := readFileConfig(t, configPath)
if cfg.ActiveProvider != "cli-name" {
t.Fatalf("ActiveProvider = %q, want cli-name", cfg.ActiveProvider)
}
if len(cfg.Providers) != 1 {
t.Fatalf("Providers = %#v, want one provider", cfg.Providers)
}
profile := cfg.Providers[0]
if profile.Name != "cli-name" || profile.BaseURL != "https://cli.example/v1" || profile.Model != "selection-model" {
t.Fatalf("stored provider = %#v, want CLI name/baseURL and selection model", profile)
}
}
func TestVerifySetupProviderDistinguishesMissingFromRejectedKey(t *testing.T) {
// AUDIT-M1: verifying a remote provider with no key must say "no API key found",
// not probe and report "the provider rejected the API key". The probe must not run.
probed := false
deps := appDeps{probeProviderHealth: func(context.Context, providerhealth.Options) providerhealth.Result {
probed = true
return providerhealth.Result{}
}}
_, err := verifySetupProvider(deps, config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4.1",
})
if err == nil || !strings.Contains(err.Error(), "no API key found") {
t.Fatalf("missing key should report 'no API key found', got %v", err)
}
if probed {
t.Fatal("must not probe the endpoint when no key is configured")
}
// A keyless LOCAL provider still probes (no key needed).
probed = false
_, _ = verifySetupProvider(deps, config.ProviderProfile{
Name: "ollama",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://localhost:11434/v1",
Model: "llama3",
})
if !probed {
t.Fatal("a keyless local provider should still be probed")
}
}
func TestSaveSetupProviderRejectsAtomicPlaceholderBeforeConfigAccess(t *testing.T) {
for _, model := range []string{"", "local-model", " local-model "} {
accessed := false
deps := appDeps{userConfigPath: func() (string, error) { accessed = true; return filepath.Join(t.TempDir(), "config.json"), nil }}
_, err := saveSetupProvider(deps, tui.SetupSelection{CatalogID: "atomic-chat-local", Model: model}, setupSaveOptions{})
if err == nil || !strings.Contains(err.Error(), "--model") || accessed {
t.Fatalf("invalid model %q reached config access: accessed=%v err=%v", model, accessed, err)
}
}
}