v0.4.10 5ac0009

auth_test.go RawHistoryFolder

1package auth
2
3import (
4	"context"
5	"io"
6	"net"
7	"net/http"
8	"net/http/httptest"
9	"net/url"
10	"path/filepath"
11	"strings"
12	"testing"
13	"time"
14)
15
16func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) {
17	SetExtraAPIKeyProviders([]string{"my-company"})
18	t.Cleanup(func() { SetExtraAPIKeyProviders(nil) })
19
20	if err := ProbeAPIKey(context.Background(), "my-company", "test-key"); err != nil {
21		t.Fatalf("ProbeAPIKey custom provider: %v", err)
22	}
23}
24
25func TestProbeGondolaAPIKeyUsesAuthenticatedBalanceEndpoint(t *testing.T) {
26	client := &http.Client{Transport: authRoundTripFunc(func(req *http.Request) (*http.Response, error) {
27		if req.URL.String() != "https://api.gondola-ai.com/v1/balance" {
28			t.Fatalf("probe URL = %q", req.URL.String())
29		}
30		if got := req.Header.Get("authorization"); got != "Bearer gnd_test" {
31			t.Fatalf("authorization = %q", got)
32		}
33		return &http.Response{
34			StatusCode: http.StatusUnauthorized,
35			Body:       io.NopCloser(strings.NewReader(`{"error":"invalid key"}`)),
36			Header:     make(http.Header),
37			Request:    req,
38		}, nil
39	})}
40
41	err := probeAPIKey(context.Background(), "gondola", "gnd_test", client)
42	if err == nil || !strings.Contains(err.Error(), "rejected the key") {
43		t.Fatalf("probeAPIKey error = %v", err)
44	}
45}
46
47type authRoundTripFunc func(*http.Request) (*http.Response, error)
48
49func (f authRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
50	return f(req)
51}
52
53func TestStoreRoundTrip(t *testing.T) {
54	dir := t.TempDir()
55	s := NewStore(filepath.Join(dir, "auth.json"))
56
57	if err := s.SetAPIKey("anthropic", "sk-ant-xyz"); err != nil {
58		t.Fatal(err)
59	}
60	c, err := s.Load()
61	if err != nil {
62		t.Fatal(err)
63	}
64	if c.Anthropic.APIKey != "sk-ant-xyz" {
65		t.Fatalf("got %+v", c)
66	}
67	if !c.Has("anthropic") || c.Method("anthropic") != "apikey" {
68		t.Fatalf("method mismatch: %q", c.Method("anthropic"))
69	}
70
71	tok := OAuthToken{AccessToken: "tok", RefreshToken: "ref", Expiry: time.Now().Add(1 * time.Hour)}
72	if err := s.SetOAuth("openai", tok); err != nil {
73		t.Fatal(err)
74	}
75	c, _ = s.Load()
76	if c.OpenAI.OAuth == nil || c.OpenAI.OAuth.AccessToken != "tok" {
77		t.Fatalf("got %+v", c.OpenAI)
78	}
79	if c.Method("openai") != "oauth" {
80		t.Fatalf("method=%q", c.Method("openai"))
81	}
82
83	if err := s.Clear("anthropic"); err != nil {
84		t.Fatal(err)
85	}
86	c, _ = s.Load()
87	if c.Has("anthropic") {
88		t.Fatalf("expected cleared, got %+v", c.Anthropic)
89	}
90}
91
92func TestPKCE(t *testing.T) {
93	p, err := NewPKCE()
94	if err != nil {
95		t.Fatal(err)
96	}
97	if len(p.Verifier) < 40 || len(p.Challenge) < 40 {
98		t.Fatalf("short pkce: %+v", p)
99	}
100	if p.Verifier == p.Challenge {
101		t.Fatal("verifier == challenge")
102	}
103}
104
105func TestStartManualOAuthRejectsOpenAIWithoutCallback(t *testing.T) {
106	m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json")))
107	if _, err := m.StartManualOAuth("openai-codex"); err == nil {
108		t.Fatal("expected manual OpenAI OAuth to be rejected")
109	} else if !strings.Contains(err.Error(), "manual code login is not supported") {
110		t.Fatalf("unexpected error: %v", err)
111	}
112}
113
114func TestBrowserOAuthSharesTransactionWithPastedCode(t *testing.T) {
115	for _, provider := range []string{"anthropic", "openai-codex"} {
116		t.Run(provider, func(t *testing.T) {
117			m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json")))
118			m.openBrowser = false
119			t.Cleanup(m.Close)
120			loginURL, err := m.StartOAuth(provider)
121			if err != nil {
122				if strings.Contains(err.Error(), "bind ") {
123					t.Skipf("callback port unavailable: %v", err)
124				}
125				t.Fatal(err)
126			}
127			u, err := url.Parse(loginURL)
128			if err != nil {
129				t.Fatal(err)
130			}
131			state := u.Query().Get("state")
132			if m.manualOp == nil || m.manualState != state || m.manualPKCE.Challenge != u.Query().Get("code_challenge") {
133				t.Fatal("pasted-code path does not share the browser transaction")
134			}
135			if err := m.CompleteManualOAuth(context.Background(), "code#wrong-state"); err == nil || !strings.Contains(err.Error(), "state mismatch") {
136				t.Fatalf("wrong state accepted: %v", err)
137			}
138			if err := m.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" {
139				t.Fatalf("shared transaction unavailable after bad code: %v", err)
140			}
141			m.CancelOAuth()
142			if err := m.CompleteManualOAuth(context.Background(), "code#"+state); err == nil || !strings.Contains(err.Error(), "no manual oauth flow") {
143				t.Fatalf("canceled flow still accepts code: %v", err)
144			}
145		})
146	}
147}
148
149func TestPastedOAuthCodeUsesBrowserPKCE(t *testing.T) {
150	requests := make(chan url.Values, 2)
151	tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152		_ = r.ParseForm()
153		requests <- r.PostForm
154		w.Header().Set("content-type", "application/json")
155		_, _ = w.Write([]byte(`{"access_token":"test-token","refresh_token":"test-refresh","expires_in":3600}`))
156	}))
157	defer tokenServer.Close()
158	old := OpenAIOAuth.TokenURL
159	OpenAIOAuth.TokenURL = tokenServer.URL
160	t.Cleanup(func() { OpenAIOAuth.TokenURL = old })
161
162	for _, method := range []string{"paste", "callback"} {
163		t.Run(method, func(t *testing.T) {
164			store := NewStore(filepath.Join(t.TempDir(), "auth.json"))
165			m := NewManager(store)
166			m.openBrowser = false
167			t.Cleanup(m.Close)
168			loginURL, err := m.StartOAuth("openai-codex")
169			if err != nil {
170				if strings.Contains(err.Error(), "bind ") {
171					t.Skipf("callback port unavailable: %v", err)
172				}
173				t.Fatal(err)
174			}
175			u, err := url.Parse(loginURL)
176			if err != nil {
177				t.Fatal(err)
178			}
179			verifier := m.manualPKCE.Verifier
180			redirect := u.Query().Get("redirect_uri")
181			codeURL := redirect + "?code=test-code&state=" + url.QueryEscape(u.Query().Get("state"))
182			if method == "paste" {
183				if err := m.CompleteManualOAuth(context.Background(), codeURL); err != nil {
184					t.Fatal(err)
185				}
186			} else {
187				resp, err := http.Get(codeURL)
188				if err != nil {
189					t.Fatal(err)
190				}
191				resp.Body.Close()
192				if resp.StatusCode != http.StatusOK {
193					t.Fatalf("callback status = %d", resp.StatusCode)
194				}
195			}
196			select {
197			case ev := <-m.Events():
198				if ev.Kind != "started" {
199					t.Fatalf("event = %+v", ev)
200				}
201			case <-time.After(2 * time.Second):
202				t.Fatal("no started event")
203			}
204			select {
205			case ev := <-m.Events():
206				if ev.Kind != "success" || ev.Provider != "openai-codex" {
207					t.Fatalf("event = %+v", ev)
208				}
209			case <-time.After(2 * time.Second):
210				t.Fatal("no success event")
211			}
212			select {
213			case form := <-requests:
214				if form.Get("code_verifier") != verifier || form.Get("redirect_uri") != redirect || form.Get("code") != "test-code" {
215					t.Fatalf("exchange did not use browser transaction: %v", form)
216				}
217			case <-time.After(2 * time.Second):
218				t.Fatal("no token exchange")
219			}
220			creds, err := store.Load()
221			if err != nil {
222				t.Fatal(err)
223			}
224			if creds.OpenAI.OAuth == nil || creds.OpenAI.OAuth.AccessToken != "test-token" {
225				t.Fatalf("stored credential: %v", creds.OpenAI.OAuth)
226			}
227		})
228	}
229}
230
231func TestAuthorizeURL(t *testing.T) {
232	p, _ := NewPKCE()
233	u, state, err := AnthropicOAuth.AuthorizeURL(p)
234	if err != nil {
235		t.Fatal(err)
236	}
237	parsed, err := url.Parse(u)
238	if err != nil {
239		t.Fatal(err)
240	}
241	q := parsed.Query()
242	if q.Get("client_id") != AnthropicOAuth.ClientID {
243		t.Fatal("missing client_id")
244	}
245	if q.Get("code_challenge_method") != "S256" {
246		t.Fatal("wrong challenge method")
247	}
248	// anthropic uses state == verifier
249	if q.Get("state") != p.Verifier {
250		t.Fatalf("state mismatch: %q vs verifier %q", q.Get("state"), p.Verifier)
251	}
252	if state != p.Verifier {
253		t.Fatalf("returned state != verifier")
254	}
255	if q.Get("redirect_uri") != AnthropicOAuth.RedirectURI() {
256		t.Fatalf("redirect_uri=%q", q.Get("redirect_uri"))
257	}
258	if q.Get("code") != "true" {
259		t.Fatalf("missing code=true extra arg")
260	}
261}
262
263func TestOpenAIAuthorizeURL(t *testing.T) {
264	p, _ := NewPKCE()
265	u, state, err := OpenAIOAuth.AuthorizeURL(p)
266	if err != nil {
267		t.Fatal(err)
268	}
269	parsed, _ := url.Parse(u)
270	q := parsed.Query()
271	if q.Get("redirect_uri") != "http://localhost:1455/auth/callback" {
272		t.Fatalf("redirect_uri=%q", q.Get("redirect_uri"))
273	}
274	if state == p.Verifier {
275		t.Fatal("openai state should be random, not verifier")
276	}
277	if q.Get("originator") != "zot" {
278		t.Fatalf("originator=%q", q.Get("originator"))
279	}
280}
281
282func TestServerIndexAndAPIKey(t *testing.T) {
283	s, err := NewServer()
284	if err != nil {
285		t.Fatal(err)
286	}
287	defer s.Shutdown(context.Background())
288
289	// Swap the probe out for a fake that always succeeds.
290	s.probeFn = func(ctx context.Context, provider, key string) error { return nil }
291
292	resp, err := http.Get(s.URL() + "/")
293	if err != nil {
294		t.Fatal(err)
295	}
296	defer resp.Body.Close()
297	if resp.StatusCode != 200 {
298		t.Fatalf("index status %d", resp.StatusCode)
299	}
300
301	resp, err = http.Get(s.URL() + "/apikey?provider=anthropic")
302	if err != nil {
303		t.Fatal(err)
304	}
305	resp.Body.Close()
306	if resp.StatusCode != 200 {
307		t.Fatalf("apikey form status %d", resp.StatusCode)
308	}
309
310	// POST a fake key and expect a success result on the channel.
311	_, err = http.PostForm(s.URL()+"/apikey", url.Values{
312		"provider": {"anthropic"},
313		"api_key":  {"sk-ant-test"},
314	})
315	if err != nil {
316		t.Fatal(err)
317	}
318	select {
319	case r := <-s.Result():
320		if r.Err != nil {
321			t.Fatalf("unexpected err: %v", r.Err)
322		}
323		if r.Provider != "anthropic" || r.APIKey != "sk-ant-test" || r.Method != "apikey" {
324			t.Fatalf("bad result: %+v", r)
325		}
326	case <-time.After(2 * time.Second):
327		t.Fatal("no result")
328	}
329}
330
331func TestServerCallback(t *testing.T) {
332	s, err := NewServer()
333	if err != nil {
334		t.Fatal(err)
335	}
336	defer s.Shutdown(context.Background())
337
338	u := s.URL() + "/callback?code=abc123&state=anthropic:xyz"
339	resp, err := http.Get(u)
340	if err != nil {
341		t.Fatal(err)
342	}
343	resp.Body.Close()
344
345	select {
346	case r := <-s.Result():
347		if r.Provider != "anthropic" || r.Code != "abc123" || r.State != "anthropic:xyz" || r.Method != "oauth" {
348			t.Fatalf("bad result: %+v", r)
349		}
350	case <-time.After(2 * time.Second):
351		t.Fatal("no result")
352	}
353}
354
355func TestCallbackServerRoundTrip(t *testing.T) {
356	// Use a provider config with an ephemeral port so tests stay hermetic.
357	op := OAuthProvider{
358		Name:         "anthropic",
359		RedirectHost: "127.0.0.1",
360		RedirectPort: 0, // let kernel pick
361		RedirectPath: "/callback",
362	}
363	// We need a real fixed port for CallbackServer.NewCallbackServer; find one.
364	l, err := net.Listen("tcp", "127.0.0.1:0")
365	if err != nil {
366		t.Fatal(err)
367	}
368	port := l.Addr().(*net.TCPAddr).Port
369	l.Close()
370	op.RedirectPort = port
371
372	cs, err := NewCallbackServer(op, "thestate")
373	if err != nil {
374		t.Fatal(err)
375	}
376	defer cs.Shutdown()
377
378	go func() {
379		time.Sleep(50 * time.Millisecond)
380		_, _ = http.Get(cs.URL() + "?code=abc&state=thestate")
381	}()
382	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
383	defer cancel()
384	r, err := cs.Result(ctx)
385	if err != nil {
386		t.Fatal(err)
387	}
388	if r.Code != "abc" || r.State != "thestate" {
389		t.Fatalf("bad result: %+v", r)
390	}
391}
392
393func TestCallbackServerStateMismatch(t *testing.T) {
394	l, _ := net.Listen("tcp", "127.0.0.1:0")
395	port := l.Addr().(*net.TCPAddr).Port
396	l.Close()
397	op := OAuthProvider{Name: "anthropic", RedirectHost: "127.0.0.1", RedirectPort: port, RedirectPath: "/callback"}
398	cs, err := NewCallbackServer(op, "expected")
399	if err != nil {
400		t.Fatal(err)
401	}
402	defer cs.Shutdown()
403
404	go func() {
405		time.Sleep(50 * time.Millisecond)
406		_, _ = http.Get(cs.URL() + "?code=abc&state=wrong")
407	}()
408	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
409	defer cancel()
410	r, err := cs.Result(ctx)
411	if err != nil {
412		t.Fatal(err)
413	}
414	if r.Err == nil || !strings.Contains(r.Err.Error(), "state") {
415		t.Fatalf("want state error, got %+v", r)
416	}
417}
418
419func TestServerCallbackError(t *testing.T) {
420	s, err := NewServer()
421	if err != nil {
422		t.Fatal(err)
423	}
424	defer s.Shutdown(context.Background())
425
426	_, _ = http.Get(s.URL() + "/callback?error=access_denied&state=openai:xyz")
427	select {
428	case r := <-s.Result():
429		if r.Err == nil || r.Provider != "openai" {
430			t.Fatalf("bad result: %+v", r)
431		}
432		if !strings.Contains(r.Err.Error(), "access_denied") {
433			t.Fatalf("missing reason: %v", r.Err)
434		}
435	case <-time.After(2 * time.Second):
436		t.Fatal("no result")
437	}
438}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close