mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* fix(auth): route `zero auth login chatgpt` to the dedicated ChatGPT flow `zero auth login chatgpt` went through the generic OAuth manager loopback path, which builds a random-port http://127.0.0.1:<port>/callback redirect_uri with no extra authorize params. OpenAI's ChatGPT client registration requires exactly http://localhost:1455/auth/callback plus the id_token_add_organizations, codex_cli_simplified_flow, and originator params, so the authorize endpoint rejected the request with an "Authentication Error" before any browser interaction. Route the chatgpt provider to the existing dedicated ChatGPT login (provideroauth.ChatGPTLogin), which already binds the fixed port, uses the localhost host, the /auth/callback path, and sends the required params. `zero auth login chatgpt` now behaves identically to `zero auth chatgpt`. --device is rejected with a clear message since ChatGPT has no device-code path. Fixes #430 * fix(auth): reject --scope on ChatGPT login instead of silently dropping it `zero auth login chatgpt --scope <x>` reached the dedicated ChatGPT flow, which ignores the generic manager's ExtraScopes — the scopes were silently dropped while --device was explicitly rejected. Reject --scope up front with a clear message, since the Codex client registration pins a fixed scope set (incl. api.connectors.*) that custom scopes would risk dropping. Addresses CodeRabbit review on #443.
200 lines
7.8 KiB
Go
200 lines
7.8 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/oauth"
|
|
)
|
|
|
|
// withAuthStore points the provider OAuth store at a temp file for the test.
|
|
func withAuthStore(t *testing.T) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
|
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
|
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 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())
|
|
}
|
|
}
|