1package modes
2
3import (
4 "context"
5 "net/url"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "github.com/patriceckhart/zot/packages/provider/auth"
11 "github.com/patriceckhart/zot/packages/tui"
12)
13
14func TestLoginDialogProviderPickerFiltersLikeModelPicker(t *testing.T) {
15 d := newLoginDialog()
16 d.step = loginStepProvider
17 d.method = "apikey"
18 d.status = map[string]string{}
19 for _, r := range "llamacpp" {
20 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
21 }
22 options := d.providerOptions()
23 if len(options) != 1 || options[0] != "llama.cpp" {
24 t.Fatalf("options = %v", options)
25 }
26 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
27 if d.step != loginStepLlamaURL {
28 t.Fatalf("step = %v", d.step)
29 }
30}
31
32func TestLoginDialogProviderPickerShowsNoMatches(t *testing.T) {
33 d := newLoginDialog()
34 d.step = loginStepProvider
35 d.method = "apikey"
36 d.status = map[string]string{}
37 d.providerQuery = "not-a-provider"
38 if options := d.providerOptions(); len(options) != 0 {
39 t.Fatalf("options = %v", options)
40 }
41 d.HandleKey(tui.Key{Kind: tui.KeyPageDown})
42 if d.cursor != 0 {
43 t.Fatalf("cursor = %d", d.cursor)
44 }
45 if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action != (loginDialogAction{}) {
46 t.Fatalf("action = %+v", action)
47 }
48}
49
50func TestLoginDialogLlamaCPPValidatesURLAndAcceptsOptionalKey(t *testing.T) {
51 d := newLoginDialog()
52 d.step = loginStepLlamaURL
53 d.provider = "llama.cpp"
54 d.method = "apikey"
55 d.llamaEd = tui.NewEditor("")
56
57 d.llamaEd.SetValue("file:///tmp/router")
58 if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.SaveLlama || d.step != loginStepLlamaURL {
59 t.Fatalf("invalid URL advanced dialog: action=%+v step=%v", action, d.step)
60 }
61 if !strings.Contains(d.message, "http or https") {
62 t.Fatalf("validation message = %q", d.message)
63 }
64
65 d.llamaEd.SetValue("http://127.0.0.1:8080/v1/")
66 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
67 if d.step != loginStepLlamaKey || d.llamaURL != "http://127.0.0.1:8080" {
68 t.Fatalf("step=%v URL=%q", d.step, d.llamaURL)
69 }
70 action := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
71 if !action.SaveLlama || action.LlamaURL != "http://127.0.0.1:8080" || action.LlamaAPIKey != "" {
72 t.Fatalf("action = %+v", action)
73 }
74}
75
76func TestLoginDialogOpenAIOAuthAcceptsCodeFromSameTransaction(t *testing.T) {
77 d := newLoginDialog()
78 d.Open(t.TempDir())
79 d.method = "oauth"
80 d.provider = "openai-codex"
81 d.ShowWaiting("http://localhost:1455/auth/callback")
82
83 text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))
84 if !strings.Contains(text, "paste the authorization code") {
85 t.Fatal("OpenAI OAuth dialog does not offer pasted code entry")
86 }
87 d.codeEd.SetValue("code#state")
88 if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.SubmitCode != "code#state" {
89 t.Fatalf("code submission = %+v", action)
90 }
91}
92
93func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) {
94 for _, tc := range []struct {
95 name string
96 choice int
97 manual bool
98 step loginStep
99 }{
100 {name: "browser", choice: 0, step: loginStepWaiting},
101 {name: "copy code", choice: 1, manual: true, step: loginStepPasteCode},
102 } {
103 t.Run(tc.name, func(t *testing.T) {
104 d := newLoginDialog()
105 d.Open(t.TempDir())
106 d.method = "oauth"
107 d.step = loginStepProvider
108 for idx, p := range d.providerOptions() {
109 if p == "anthropic" {
110 d.cursor = idx
111 break
112 }
113 }
114 if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.StartOAuth || action.StartManual || d.step != loginStepOAuthMethod {
115 t.Fatalf("provider selection started OAuth before choosing a flow: action=%+v step=%v", action, d.step)
116 }
117 for i := 0; i < tc.choice; i++ {
118 d.HandleKey(tui.Key{Kind: tui.KeyDown})
119 }
120 action := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
121 if action.StartManual != tc.manual || action.StartOAuth == tc.manual || d.step != tc.step {
122 t.Fatalf("flow action=%+v step=%v", action, d.step)
123 }
124 if tc.manual {
125 d.ShowPasteCode("https://example.com/oauth/authorize")
126 } else {
127 d.ShowWaiting("https://example.com/oauth/authorize")
128 }
129 text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))
130 if !strings.Contains(text, "paste the authorization code") {
131 t.Fatalf("missing code input for %s flow: %s", tc.name, text)
132 }
133 })
134 }
135}
136
137func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) {
138 manager := auth.NewManager(auth.NewStore(filepath.Join(t.TempDir(), "auth.json")))
139 t.Cleanup(manager.Close)
140 i := NewInteractive(InteractiveConfig{AuthManager: manager})
141 i.dialog.Open(t.TempDir())
142 i.dialog.method = "oauth"
143 i.dialog.provider = "anthropic"
144 i.dialog.step = loginStepOAuthMethod
145 i.dialog.HandleKey(tui.Key{Kind: tui.KeyDown})
146 act := i.dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
147 if !act.StartManual || act.Provider != "anthropic" {
148 t.Fatalf("expected manual Anthropic transaction, got %+v", act)
149 }
150 i.startManualOAuthFlow(act.Provider)
151 i.handleAuthEvent(<-manager.Events())
152 if i.dialog.step != loginStepPasteCode {
153 t.Fatalf("step = %v, want paste code", i.dialog.step)
154 }
155 u, err := url.Parse(i.dialog.url)
156 if err != nil {
157 t.Fatal(err)
158 }
159 if got := u.Query().Get("redirect_uri"); got != auth.AnthropicManualOAuth.RedirectURI() {
160 t.Fatalf("redirect URI = %q, want copy-code redirect", got)
161 }
162 if err := manager.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" {
163 t.Fatalf("manual transaction not ready for code entry: %v", err)
164 }
165}
166
167func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) {
168 d := newLoginDialog()
169 d.Open(t.TempDir())
170 d.method = "oauth"
171 d.provider = "anthropic"
172 d.ShowPasteCode("https://example.com/oauth/authorize?code_challenge=abc&state=xyz")
173
174 lines := padDialogFrame(d.Render(tui.Theme{}, 80))
175 row, _ := d.CursorPos(80)
176 if row < 0 || row >= len(lines) {
177 t.Fatalf("CursorPos row = %d outside rendered lines %d", row, len(lines))
178 }
179 if got := stripANSIBytes(lines[row]); !strings.Contains(got, "▌") {
180 t.Fatalf("CursorPos row %d = %q; want editor input row", row, got)
181 }
182}