mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
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]>
415 lines
16 KiB
Go
415 lines
16 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Twigpine/zero/internal/config"
|
|
"github.com/Twigpine/zero/internal/oauth"
|
|
"github.com/Twigpine/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
|
|
{"auth", "status", "--confirm"}, // confirm is reset-only
|
|
{"auth", "reset", "--confirm", "--watch"}, // confirmation does not allow unrelated flags
|
|
}
|
|
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", "reset --confirm", "--device"} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("help missing %q:\n%s", want, stdout.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunAuthResetRequiresConfirmation(t *testing.T) {
|
|
for _, args := range [][]string{{}, {"--json"}} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
path := withAuthStore(t)
|
|
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "synthetic-reset-test-token"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
code := runAuthReset(args, &stdout, &stderr, appDeps{})
|
|
after, readErr := os.ReadFile(path)
|
|
if readErr != nil || !bytes.Equal(before, after) {
|
|
t.Fatalf("unconfirmed reset changed the store: %v", readErr)
|
|
}
|
|
if code == exitSuccess || !strings.Contains(stderr.String(), "--confirm") || stdout.Len() != 0 {
|
|
t.Fatalf("unconfirmed reset: exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunAuthResetJSON(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: "synthetic-reset-test-token"}); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "reset", "--confirm", "--json"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
|
}
|
|
var payload struct {
|
|
Reset bool `json:"reset"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode JSON: %v (stdout=%q)", err, stdout.String())
|
|
}
|
|
if !payload.Reset {
|
|
t.Fatalf("payload = %+v, want reset=true", payload)
|
|
}
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("status exit = %d stderr=%s", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
|
|
t.Fatalf("expected empty store after JSON reset, got: %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAuthReset(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: "secret"}); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "reset", "--confirm"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Reset OAuth token store") {
|
|
t.Fatalf("stdout = %q", stdout.String())
|
|
}
|
|
|
|
// Verify store is now empty.
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("status exit = %d", code)
|
|
}
|
|
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
|
|
t.Fatalf("expected empty store after reset, got: %q", 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
|
|
}
|