v0.46.0 ea0e112

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

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 477

↑↓ moveenter openesc close