mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* fix(auth): persist OpenRouter API key after login zero auth openrouter minted a key but only printed it, leaving the provider unusable unless the user manually exported it or edited config by hand. Now the key is saved into the credential store, the provider profile is created/activated, and stale inline/env key fields are cleared. Auth-failure hints also now point at zero setup and zero auth openrouter instead of the vaguer zero auth. Co-Authored-By: Claude Sonnet 5 <[email protected]> * fix(auth): sanitize minted key and roll back orphaned credential on save failure Addresses PR review comments: trim the OpenRouter key right after login so both the save path and the manual fallback instructions use the sanitized value, and delete the just-written credential-store entry if persisting the stored-key marker in config.json fails, so a partial failure doesn't leave an orphaned secret. Co-Authored-By: Claude Sonnet 5 <[email protected]> --------- Co-authored-by: Claude Sonnet 5 <[email protected]>
324 lines
12 KiB
Go
324 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/oauth"
|
|
"github.com/Gitlawb/zero/internal/provideroauth"
|
|
)
|
|
|
|
// withAuthStore points the provider OAuth store at a temp file for the test,
|
|
// pinning the file backend so an inherited ZERO_OAUTH_STORAGE=keyring can't
|
|
// ignore the temp path and hit the OS keychain.
|
|
func withAuthStore(t *testing.T) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
|
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
|
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
|
return path
|
|
}
|
|
|
|
func TestRunAuthRejectsInvalidStorageMode(t *testing.T) {
|
|
withAuthStore(t)
|
|
// A mistyped value must fail fast, not silently fall back to plaintext while
|
|
// the user believes encryption is active.
|
|
t.Setenv("ZERO_OAUTH_STORAGE", "encryptd")
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatalf("invalid ZERO_OAUTH_STORAGE should fail, got success; stdout=%q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "ZERO_OAUTH_STORAGE") {
|
|
t.Fatalf("error should name the offending env var, stderr=%q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAuthStatusEmpty(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
|
|
t.Fatalf("status output = %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAuthStatusReportsLoginWithoutSecret(t *testing.T) {
|
|
path := withAuthStore(t)
|
|
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{
|
|
AccessToken: "super-secret", RefreshToken: "super-secret-rt", Account: "[email protected]",
|
|
}); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "demo") || !strings.Contains(out, "[email protected]") {
|
|
t.Fatalf("status should show provider + account: %q", out)
|
|
}
|
|
if strings.Contains(out, "super-secret") {
|
|
t.Fatalf("status leaked token material: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestRunAuthLogoutNothing(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "logout", "demo"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "No stored credential for demo") {
|
|
t.Fatalf("logout output = %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAuthLoginValidation(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
// Missing provider.
|
|
if code := runWithDeps([]string{"auth", "login"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("login with no provider should fail")
|
|
}
|
|
// --json is rejected for the interactive login.
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := runWithDeps([]string{"auth", "login", "demo", "--json"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("login --json should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestRunAuthLoginUnknownProvider(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "login", "does-not-exist"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("unknown provider login should fail")
|
|
}
|
|
if !strings.Contains(stderr.String(), "not configured") {
|
|
t.Fatalf("stderr = %q, want not-configured error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAuthRefreshNoToken(t *testing.T) {
|
|
withAuthStore(t)
|
|
t.Setenv("ZERO_OAUTH_DEMO_CLIENT_ID", "client") // so config resolves; refresh still fails (no token)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "refresh", "demo"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("refresh with no stored token should fail")
|
|
}
|
|
}
|
|
|
|
func TestRunAuthRejectsWrongFlags(t *testing.T) {
|
|
withAuthStore(t)
|
|
cases := [][]string{
|
|
{"auth", "login", "demo", "--watch"}, // watch is refresh-only
|
|
{"auth", "login", "demo", "--json"}, // json not for interactive login
|
|
{"auth", "status", "demo", "--device"}, // device is login-only
|
|
{"auth", "logout", "demo", "--scope", "x"}, // scope is login-only
|
|
{"auth", "refresh", "demo", "--json"}, // json not for refresh
|
|
{"auth", "login", "demo", "--scope", ""}, // empty scope rejected
|
|
}
|
|
for _, args := range cases {
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps(args, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Errorf("args %v should be rejected, got success", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunAuthOpenRouterRejectsArgs(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
// An unexpected arg/flag must fail fast, not silently run the login.
|
|
if code := runWithDeps([]string{"auth", "openrouter", "--json"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatalf("openrouter with an unexpected flag should fail; stdout=%q", stdout.String())
|
|
}
|
|
// --help still works.
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := runWithDeps([]string{"auth", "openrouter", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("openrouter --help should succeed, stderr=%q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAuthOpenRouterSavesMintedKey(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := runWithDeps([]string{"auth", "openrouter"}, &stdout, &stderr, appDeps{
|
|
userConfigPath: func() (string, error) { return configPath, nil },
|
|
openRouterLogin: func(context.Context, provideroauth.OpenRouterOptions) (string, error) {
|
|
return "sk-openrouter-test", nil
|
|
},
|
|
})
|
|
|
|
if code != exitSuccess {
|
|
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "new API key saved") {
|
|
t.Fatalf("expected saved-key confirmation, got %q", stdout.String())
|
|
}
|
|
cfg := readCLIConfigFixture(t, configPath)
|
|
if cfg.ActiveProvider != "openrouter" || len(cfg.Providers) != 1 {
|
|
t.Fatalf("config = %#v", cfg)
|
|
}
|
|
profile := cfg.Providers[0]
|
|
if profile.Name != "openrouter" || profile.CatalogID != "openrouter" || !profile.APIKeyStored || profile.APIKey != "" || profile.APIKeyEnv != "" {
|
|
t.Fatalf("provider not stored-key sanitized: %#v", profile)
|
|
}
|
|
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key, ok, err := store.Get("openrouter")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !ok || key != "sk-openrouter-test" {
|
|
t.Fatalf("stored key = %q, %v", key, ok)
|
|
}
|
|
}
|
|
|
|
func TestRunAuthHelp(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("exit = %d", code)
|
|
}
|
|
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "--device"} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("help missing %q:\n%s", want, stdout.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
|
|
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
|
|
// authorize params), not the generic manager path. The generic login accepts
|
|
// --device, so a ChatGPT-specific rejection proves the routing took effect.
|
|
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
|
|
// without the required extra params, so OpenAI's authorize endpoint rejected it.
|
|
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
|
|
}
|
|
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
|
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
|
|
}
|
|
// Case-insensitive provider name should still route.
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("auth login ChatGPT --device should be rejected")
|
|
}
|
|
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
|
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
|
|
}
|
|
}
|
|
|
|
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
|
|
// must not be silently dropped on the ChatGPT path. The Codex client
|
|
// registration pins a fixed scope set (incl. api.connectors.*), so custom
|
|
// scopes are rejected up front rather than plumbed through.
|
|
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("auth login chatgpt --scope should be rejected")
|
|
}
|
|
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
|
|
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestEnsureLoginProviderProfileAddsProviderWithoutStealingActive(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
|
|
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
|
t.Fatalf("seed config: %v", err)
|
|
}
|
|
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
|
|
|
line := ensureLoginProviderProfile(deps, "chatgpt")
|
|
if !strings.Contains(line, `Added provider "chatgpt"`) {
|
|
t.Fatalf("expected added-provider guidance, got %q", line)
|
|
}
|
|
if !strings.Contains(line, "zero providers use chatgpt") {
|
|
t.Fatalf("expected switch hint, got %q", line)
|
|
}
|
|
|
|
cfg := readCLIConfigFixture(t, configPath)
|
|
if cfg.ActiveProvider != "opengateway" {
|
|
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
|
|
}
|
|
names := make([]string, 0, len(cfg.Providers))
|
|
for _, provider := range cfg.Providers {
|
|
names = append(names, provider.Name)
|
|
}
|
|
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
|
|
t.Fatalf("expected chatgpt profile appended, got %v", names)
|
|
}
|
|
|
|
// A second login must be a no-op with switch guidance, not a duplicate.
|
|
line = ensureLoginProviderProfile(deps, "chatgpt")
|
|
if !strings.Contains(line, "already configured") {
|
|
t.Fatalf("expected already-configured guidance, got %q", line)
|
|
}
|
|
cfg = readCLIConfigFixture(t, configPath)
|
|
if len(cfg.Providers) != 2 {
|
|
t.Fatalf("repeat login duplicated the profile: %d providers", len(cfg.Providers))
|
|
}
|
|
}
|
|
|
|
func TestEnsureLoginProviderProfileActivatesOnFreshConfig(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
|
|
|
line := ensureLoginProviderProfile(deps, "chatgpt")
|
|
if !strings.Contains(line, "set it active") {
|
|
t.Fatalf("fresh config should adopt the login as active, got %q", line)
|
|
}
|
|
cfg := readCLIConfigFixture(t, configPath)
|
|
if cfg.ActiveProvider != "chatgpt" {
|
|
t.Fatalf("active provider = %q, want chatgpt", cfg.ActiveProvider)
|
|
}
|
|
}
|
|
|
|
func TestEnsureLoginProviderProfileSkipsNonCatalogProviders(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
|
|
|
if line := ensureLoginProviderProfile(deps, "my-custom-oauth-server"); line != "" {
|
|
t.Fatalf("custom OAuth server must not scaffold a profile, got %q", line)
|
|
}
|
|
if _, err := os.Stat(configPath); err == nil {
|
|
t.Fatalf("config must not be created for a non-catalog login")
|
|
}
|
|
}
|
|
|
|
func readCLIConfigFixture(t *testing.T, path string) config.FileConfig {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read config: %v", err)
|
|
}
|
|
var cfg config.FileConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
t.Fatalf("decode config: %v", err)
|
|
}
|
|
return cfg
|
|
}
|