Upload images to your PDS and get instant CDN URLs via images.blue
1package auth
2
3import (
4 "context"
5 "fmt"
6 "net/http"
7 "testing"
8)
9
10func TestNewOAuthFlow(t *testing.T) {
11 flow, err := NewOAuthFlow("test.bsky.social")
12 if err != nil {
13 t.Fatalf("NewOAuthFlow() error = %v", err)
14 }
15
16 if flow == nil {
17 t.Fatal("NewOAuthFlow() returned nil")
18 }
19
20 if flow.loginIdentifier != "test.bsky.social" {
21 t.Errorf("loginIdentifier = %q, want %q", flow.loginIdentifier, "test.bsky.social")
22 }
23
24 if flow.app == nil {
25 t.Error("app should not be nil")
26 }
27
28 if flow.store == nil {
29 t.Error("store should not be nil")
30 }
31
32 if flow.authSuccess == nil {
33 t.Error("authSuccess channel should not be nil")
34 }
35
36 if flow.authError == nil {
37 t.Error("authError channel should not be nil")
38 }
39
40 if flow.httpClient == nil {
41 t.Error("httpClient should not be nil (default)")
42 }
43
44 if flow.openBrowser == nil {
45 t.Error("openBrowser should not be nil (default)")
46 }
47}
48
49func TestNewOAuthFlowEmptyIdentifier(t *testing.T) {
50 flow, err := NewOAuthFlow("")
51 if err != nil {
52 t.Fatalf("NewOAuthFlow() error = %v", err)
53 }
54
55 if flow == nil {
56 t.Fatal("NewOAuthFlow() returned nil")
57 }
58
59 if flow.loginIdentifier != "" {
60 t.Errorf("loginIdentifier = %q, want empty string", flow.loginIdentifier)
61 }
62}
63
64func TestAuthenticateRequiresIdentifier(t *testing.T) {
65 flow, err := NewOAuthFlow("")
66 if err != nil {
67 t.Fatalf("NewOAuthFlow() error = %v", err)
68 }
69
70 _, err = flow.Authenticate()
71 if err == nil {
72 t.Error("Authenticate() expected error with empty identifier, got nil")
73 }
74
75 expectedErr := "login identifier is required"
76 if err.Error() != expectedErr {
77 t.Errorf("Authenticate() error = %q, want %q", err.Error(), expectedErr)
78 }
79}
80
81// MockHTTPClient implements HTTPDoer for testing
82type MockHTTPClient struct {
83 DoFunc func(req *http.Request) (*http.Response, error)
84}
85
86func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
87 return m.DoFunc(req)
88}
89
90func TestNewOAuthFlowWithOptions(t *testing.T) {
91 mockStore := NewKeyringAuthStoreWithKeyring(NewMockKeyring())
92 mockHTTP := &MockHTTPClient{}
93 browserCalled := false
94 mockBrowser := func(url string) error {
95 browserCalled = true
96 return nil
97 }
98
99 flow, err := NewOAuthFlow("test.bsky.social",
100 WithStore(mockStore),
101 WithHTTPClient(mockHTTP),
102 WithBrowserOpener(mockBrowser),
103 )
104 if err != nil {
105 t.Fatalf("NewOAuthFlow() error = %v", err)
106 }
107
108 if flow.store != mockStore {
109 t.Error("store was not injected correctly")
110 }
111
112 if flow.httpClient != mockHTTP {
113 t.Error("httpClient was not injected correctly")
114 }
115
116 // Test browser opener was injected
117 flow.openBrowser("http://test.com")
118 if !browserCalled {
119 t.Error("openBrowser was not injected correctly")
120 }
121}
122
123func TestNewClientAppWithStore(t *testing.T) {
124 mockKeyring := NewMockKeyring()
125 mockStore := NewKeyringAuthStoreWithKeyring(mockKeyring)
126
127 app, store := NewClientApp(mockStore)
128
129 if app == nil {
130 t.Error("NewClientApp() app should not be nil")
131 }
132
133 if store != mockStore {
134 t.Error("NewClientApp() should return the injected store")
135 }
136}
137
138func TestNewClientAppWithoutStore(t *testing.T) {
139 app, store := NewClientApp()
140
141 if app == nil {
142 t.Error("NewClientApp() app should not be nil")
143 }
144
145 if store == nil {
146 t.Error("NewClientApp() should create a default store")
147 }
148}
149
150func TestParseSSEAuthData(t *testing.T) {
151 tests := []struct {
152 name string
153 input string
154 wantErr bool
155 code string
156 iss string
157 state string
158 }{
159 {
160 name: "valid auth data",
161 input: `{"code":"auth_code_123","iss":"https://bsky.social","state":"state_abc"}`,
162 wantErr: false,
163 code: "auth_code_123",
164 iss: "https://bsky.social",
165 state: "state_abc",
166 },
167 {
168 name: "valid with whitespace",
169 input: ` {"code":"code","iss":"iss","state":"state"} `,
170 wantErr: false,
171 code: "code",
172 iss: "iss",
173 state: "state",
174 },
175 {
176 name: "empty string",
177 input: "",
178 wantErr: true,
179 },
180 {
181 name: "whitespace only",
182 input: " ",
183 wantErr: true,
184 },
185 {
186 name: "invalid JSON",
187 input: "not json",
188 wantErr: true,
189 },
190 {
191 name: "missing code",
192 input: `{"iss":"https://bsky.social","state":"state_abc"}`,
193 wantErr: true,
194 },
195 {
196 name: "missing iss",
197 input: `{"code":"auth_code_123","state":"state_abc"}`,
198 wantErr: true,
199 },
200 {
201 name: "missing state",
202 input: `{"code":"auth_code_123","iss":"https://bsky.social"}`,
203 wantErr: true,
204 },
205 {
206 name: "empty code",
207 input: `{"code":"","iss":"https://bsky.social","state":"state_abc"}`,
208 wantErr: true,
209 },
210 }
211
212 for _, tt := range tests {
213 t.Run(tt.name, func(t *testing.T) {
214 result, err := parseSSEAuthData(tt.input)
215
216 if tt.wantErr {
217 if err == nil {
218 t.Errorf("parseSSEAuthData() expected error, got nil")
219 }
220 return
221 }
222
223 if err != nil {
224 t.Errorf("parseSSEAuthData() unexpected error: %v", err)
225 return
226 }
227
228 if result.Code != tt.code {
229 t.Errorf("Code = %q, want %q", result.Code, tt.code)
230 }
231 if result.Iss != tt.iss {
232 t.Errorf("Iss = %q, want %q", result.Iss, tt.iss)
233 }
234 if result.State != tt.state {
235 t.Errorf("State = %q, want %q", result.State, tt.state)
236 }
237 })
238 }
239}
240
241func TestLogoutNoSession(t *testing.T) {
242 // Logout should succeed even when no session exists
243 // Note: This uses the real keyring, but should still work
244 // because it handles the "no session" case gracefully
245 err := Logout()
246 // We can't easily test this without mocking, but we can verify it doesn't panic
247 _ = err
248}
249
250func TestIsTokenRefreshError(t *testing.T) {
251 tests := []struct {
252 name string
253 err error
254 expected bool
255 }{
256 {
257 name: "nil error",
258 err: nil,
259 expected: false,
260 },
261 {
262 name: "regular error",
263 err: fmt.Errorf("some random error"),
264 expected: false,
265 },
266 {
267 name: "network error",
268 err: fmt.Errorf("connection refused"),
269 expected: false,
270 },
271 {
272 name: "indigo token refresh error",
273 err: fmt.Errorf("failed to refresh OAuth tokens: token refresh failed (HTTP 400): invalid_grant"),
274 expected: true,
275 },
276 {
277 name: "wrapped token refresh error",
278 err: fmt.Errorf("upload failed: %w", fmt.Errorf("failed to refresh OAuth tokens: something")),
279 expected: true,
280 },
281 {
282 name: "invalid_grant only",
283 err: fmt.Errorf("invalid_grant"),
284 expected: true,
285 },
286 {
287 name: "token refresh failed",
288 err: fmt.Errorf("token refresh failed"),
289 expected: true,
290 },
291 {
292 name: "partial match - failed to refresh",
293 err: fmt.Errorf("failed to refresh OAuth tokens"),
294 expected: true,
295 },
296 }
297
298 for _, tt := range tests {
299 t.Run(tt.name, func(t *testing.T) {
300 result := IsTokenRefreshError(tt.err)
301 if result != tt.expected {
302 t.Errorf("IsTokenRefreshError(%v) = %v, want %v", tt.err, result, tt.expected)
303 }
304 })
305 }
306}
307
308func TestExecuteWithReauth_NoError(t *testing.T) {
309 // Test that when the operation succeeds, no re-auth is attempted
310 operationCalls := 0
311
312 // We can't easily mock oauth.ClientSession, so we test with nil
313 // and an operation that doesn't use the session
314 _, err := ExecuteWithReauth(context.TODO(), nil, func(s *ClientSession) error {
315 operationCalls++
316 return nil
317 })
318
319 if err != nil {
320 t.Errorf("ExecuteWithReauth() unexpected error: %v", err)
321 }
322
323 if operationCalls != 1 {
324 t.Errorf("operation called %d times, want 1", operationCalls)
325 }
326}
327
328func TestExecuteWithReauth_NonTokenError(t *testing.T) {
329 // Test that non-token errors are returned without retry
330 operationCalls := 0
331 expectedErr := fmt.Errorf("some other error")
332
333 _, err := ExecuteWithReauth(context.TODO(), nil, func(s *ClientSession) error {
334 operationCalls++
335 return expectedErr
336 })
337
338 if err != expectedErr {
339 t.Errorf("ExecuteWithReauth() error = %v, want %v", err, expectedErr)
340 }
341
342 if operationCalls != 1 {
343 t.Errorf("operation called %d times, want 1 (no retry for non-token errors)", operationCalls)
344 }
345}