v0.38.0 58133ea

auth_test.go RawHistoryFolder

1package auth
2
3import (
4	"context"
5	"net"
6	"net/http"
7	"net/url"
8	"path/filepath"
9	"strings"
10	"testing"
11	"time"
12)
13
14func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) {
15	SetExtraAPIKeyProviders([]string{"my-company"})
16	t.Cleanup(func() { SetExtraAPIKeyProviders(nil) })
17
18	if err := ProbeAPIKey(context.Background(), "my-company", "test-key"); err != nil {
19		t.Fatalf("ProbeAPIKey custom provider: %v", err)
20	}
21}
22
23func TestStoreRoundTrip(t *testing.T) {
24	dir := t.TempDir()
25	s := NewStore(filepath.Join(dir, "auth.json"))
26
27	if err := s.SetAPIKey("anthropic", "sk-ant-xyz"); err != nil {
28		t.Fatal(err)
29	}
30	c, err := s.Load()
31	if err != nil {
32		t.Fatal(err)
33	}
34	if c.Anthropic.APIKey != "sk-ant-xyz" {
35		t.Fatalf("got %+v", c)
36	}
37	if !c.Has("anthropic") || c.Method("anthropic") != "apikey" {
38		t.Fatalf("method mismatch: %q", c.Method("anthropic"))
39	}
40
41	tok := OAuthToken{AccessToken: "tok", RefreshToken: "ref", Expiry: time.Now().Add(1 * time.Hour)}
42	if err := s.SetOAuth("openai", tok); err != nil {
43		t.Fatal(err)
44	}
45	c, _ = s.Load()
46	if c.OpenAI.OAuth == nil || c.OpenAI.OAuth.AccessToken != "tok" {
47		t.Fatalf("got %+v", c.OpenAI)
48	}
49	if c.Method("openai") != "oauth" {
50		t.Fatalf("method=%q", c.Method("openai"))
51	}
52
53	if err := s.Clear("anthropic"); err != nil {
54		t.Fatal(err)
55	}
56	c, _ = s.Load()
57	if c.Has("anthropic") {
58		t.Fatalf("expected cleared, got %+v", c.Anthropic)
59	}
60}
61
62func TestPKCE(t *testing.T) {
63	p, err := NewPKCE()
64	if err != nil {
65		t.Fatal(err)
66	}
67	if len(p.Verifier) < 40 || len(p.Challenge) < 40 {
68		t.Fatalf("short pkce: %+v", p)
69	}
70	if p.Verifier == p.Challenge {
71		t.Fatal("verifier == challenge")
72	}
73}
74
75func TestAuthorizeURL(t *testing.T) {
76	p, _ := NewPKCE()
77	u, state, err := AnthropicOAuth.AuthorizeURL(p)
78	if err != nil {
79		t.Fatal(err)
80	}
81	parsed, err := url.Parse(u)
82	if err != nil {
83		t.Fatal(err)
84	}
85	q := parsed.Query()
86	if q.Get("client_id") != AnthropicOAuth.ClientID {
87		t.Fatal("missing client_id")
88	}
89	if q.Get("code_challenge_method") != "S256" {
90		t.Fatal("wrong challenge method")
91	}
92	// anthropic uses state == verifier
93	if q.Get("state") != p.Verifier {
94		t.Fatalf("state mismatch: %q vs verifier %q", q.Get("state"), p.Verifier)
95	}
96	if state != p.Verifier {
97		t.Fatalf("returned state != verifier")
98	}
99	if q.Get("redirect_uri") != AnthropicOAuth.RedirectURI() {
100		t.Fatalf("redirect_uri=%q", q.Get("redirect_uri"))
101	}
102	if q.Get("code") != "true" {
103		t.Fatalf("missing code=true extra arg")
104	}
105}
106
107func TestOpenAIAuthorizeURL(t *testing.T) {
108	p, _ := NewPKCE()
109	u, state, err := OpenAIOAuth.AuthorizeURL(p)
110	if err != nil {
111		t.Fatal(err)
112	}
113	parsed, _ := url.Parse(u)
114	q := parsed.Query()
115	if q.Get("redirect_uri") != "http://localhost:1455/auth/callback" {
116		t.Fatalf("redirect_uri=%q", q.Get("redirect_uri"))
117	}
118	if state == p.Verifier {
119		t.Fatal("openai state should be random, not verifier")
120	}
121	if q.Get("originator") != "zot" {
122		t.Fatalf("originator=%q", q.Get("originator"))
123	}
124}
125
126func TestServerIndexAndAPIKey(t *testing.T) {
127	s, err := NewServer()
128	if err != nil {
129		t.Fatal(err)
130	}
131	defer s.Shutdown(context.Background())
132
133	// Swap the probe out for a fake that always succeeds.
134	s.probeFn = func(ctx context.Context, provider, key string) error { return nil }
135
136	resp, err := http.Get(s.URL() + "/")
137	if err != nil {
138		t.Fatal(err)
139	}
140	defer resp.Body.Close()
141	if resp.StatusCode != 200 {
142		t.Fatalf("index status %d", resp.StatusCode)
143	}
144
145	resp, err = http.Get(s.URL() + "/apikey?provider=anthropic")
146	if err != nil {
147		t.Fatal(err)
148	}
149	resp.Body.Close()
150	if resp.StatusCode != 200 {
151		t.Fatalf("apikey form status %d", resp.StatusCode)
152	}
153
154	// POST a fake key and expect a success result on the channel.
155	_, err = http.PostForm(s.URL()+"/apikey", url.Values{
156		"provider": {"anthropic"},
157		"api_key":  {"sk-ant-test"},
158	})
159	if err != nil {
160		t.Fatal(err)
161	}
162	select {
163	case r := <-s.Result():
164		if r.Err != nil {
165			t.Fatalf("unexpected err: %v", r.Err)
166		}
167		if r.Provider != "anthropic" || r.APIKey != "sk-ant-test" || r.Method != "apikey" {
168			t.Fatalf("bad result: %+v", r)
169		}
170	case <-time.After(2 * time.Second):
171		t.Fatal("no result")
172	}
173}
174
175func TestServerCallback(t *testing.T) {
176	s, err := NewServer()
177	if err != nil {
178		t.Fatal(err)
179	}
180	defer s.Shutdown(context.Background())
181
182	u := s.URL() + "/callback?code=abc123&state=anthropic:xyz"
183	resp, err := http.Get(u)
184	if err != nil {
185		t.Fatal(err)
186	}
187	resp.Body.Close()
188
189	select {
190	case r := <-s.Result():
191		if r.Provider != "anthropic" || r.Code != "abc123" || r.State != "anthropic:xyz" || r.Method != "oauth" {
192			t.Fatalf("bad result: %+v", r)
193		}
194	case <-time.After(2 * time.Second):
195		t.Fatal("no result")
196	}
197}
198
199func TestCallbackServerRoundTrip(t *testing.T) {
200	// Use a provider config with an ephemeral port so tests stay hermetic.
201	op := OAuthProvider{
202		Name:         "anthropic",
203		RedirectHost: "127.0.0.1",
204		RedirectPort: 0, // let kernel pick
205		RedirectPath: "/callback",
206	}
207	// We need a real fixed port for CallbackServer.NewCallbackServer; find one.
208	l, err := net.Listen("tcp", "127.0.0.1:0")
209	if err != nil {
210		t.Fatal(err)
211	}
212	port := l.Addr().(*net.TCPAddr).Port
213	l.Close()
214	op.RedirectPort = port
215
216	cs, err := NewCallbackServer(op, "thestate")
217	if err != nil {
218		t.Fatal(err)
219	}
220	defer cs.Shutdown()
221
222	go func() {
223		time.Sleep(50 * time.Millisecond)
224		_, _ = http.Get(cs.URL() + "?code=abc&state=thestate")
225	}()
226	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
227	defer cancel()
228	r, err := cs.Result(ctx)
229	if err != nil {
230		t.Fatal(err)
231	}
232	if r.Code != "abc" || r.State != "thestate" {
233		t.Fatalf("bad result: %+v", r)
234	}
235}
236
237func TestCallbackServerStateMismatch(t *testing.T) {
238	l, _ := net.Listen("tcp", "127.0.0.1:0")
239	port := l.Addr().(*net.TCPAddr).Port
240	l.Close()
241	op := OAuthProvider{Name: "anthropic", RedirectHost: "127.0.0.1", RedirectPort: port, RedirectPath: "/callback"}
242	cs, err := NewCallbackServer(op, "expected")
243	if err != nil {
244		t.Fatal(err)
245	}
246	defer cs.Shutdown()
247
248	go func() {
249		time.Sleep(50 * time.Millisecond)
250		_, _ = http.Get(cs.URL() + "?code=abc&state=wrong")
251	}()
252	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
253	defer cancel()
254	r, err := cs.Result(ctx)
255	if err != nil {
256		t.Fatal(err)
257	}
258	if r.Err == nil || !strings.Contains(r.Err.Error(), "state") {
259		t.Fatalf("want state error, got %+v", r)
260	}
261}
262
263func TestServerCallbackError(t *testing.T) {
264	s, err := NewServer()
265	if err != nil {
266		t.Fatal(err)
267	}
268	defer s.Shutdown(context.Background())
269
270	_, _ = http.Get(s.URL() + "/callback?error=access_denied&state=openai:xyz")
271	select {
272	case r := <-s.Result():
273		if r.Err == nil || r.Provider != "openai" {
274			t.Fatalf("bad result: %+v", r)
275		}
276		if !strings.Contains(r.Err.Error(), "access_denied") {
277			t.Fatalf("missing reason: %v", r.Err)
278		}
279	case <-time.After(2 * time.Second):
280		t.Fatal("no result")
281	}
282}

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