package oauth import ( "context" "errors" "fmt" "testing" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/auth/oauth" ) func TestNewClientApp(t *testing.T) { keyPath := t.TempDir() + "/oauth-key.bin" store := oauth.NewMemStore() baseURL := "http://localhost:5000" scopes := GetDefaultScopes("*") clientApp, err := NewClientApp(baseURL, store, scopes, keyPath, "AT Container Registry") if err != nil { t.Fatalf("NewClientApp() error = %v", err) } if clientApp == nil { t.Fatal("Expected non-nil clientApp") } if clientApp.Dir == nil { t.Error("Expected directory to be set") } } func TestNewClientAppWithCustomScopes(t *testing.T) { keyPath := t.TempDir() + "/oauth-key.bin" store := oauth.NewMemStore() baseURL := "http://localhost:5000" scopes := []string{"atproto", "custom:scope"} clientApp, err := NewClientApp(baseURL, store, scopes, keyPath, "AT Container Registry") if err != nil { t.Fatalf("NewClientApp() error = %v", err) } if clientApp == nil { t.Fatal("Expected non-nil clientApp") } // Verify clientApp was created successfully // (Note: indigo's oauth.ClientApp doesn't expose scopes directly, // but we can verify it was created without error) if clientApp.Dir == nil { t.Error("Expected directory to be set") } } func TestScopesMatch(t *testing.T) { tests := []struct { name string stored []string desired []string expected bool }{ { name: "exact match", stored: []string{"atproto", "blob:image/png"}, desired: []string{"atproto", "blob:image/png"}, expected: true, }, { name: "different order", stored: []string{"blob:image/png", "atproto"}, desired: []string{"atproto", "blob:image/png"}, expected: true, }, { name: "missing scope in stored", stored: []string{"atproto"}, desired: []string{"atproto", "blob:image/png"}, expected: false, }, { name: "extra scope in stored", stored: []string{"atproto", "blob:image/png", "extra"}, desired: []string{"atproto", "blob:image/png"}, expected: false, }, { name: "both empty", stored: []string{}, desired: []string{}, expected: true, }, { name: "nil vs empty", stored: nil, desired: []string{}, expected: true, }, { name: "completely different", stored: []string{"foo", "bar"}, desired: []string{"baz", "qux"}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := ScopesMatch(tt.stored, tt.desired) if result != tt.expected { t.Errorf("ScopesMatch(%v, %v) = %v, want %v", tt.stored, tt.desired, result, tt.expected) } }) } } // ---------------------------------------------------------------------------- // Session Management (Refresher) Tests // ---------------------------------------------------------------------------- func TestNewRefresher(t *testing.T) { store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") if err != nil { t.Fatalf("NewClientApp() error = %v", err) } refresher := NewRefresher(clientApp) if refresher == nil { t.Fatal("Expected non-nil refresher") } if refresher.clientApp == nil { t.Error("Expected clientApp to be set") } } func TestRefresher_SetUISessionStore(t *testing.T) { store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") if err != nil { t.Fatalf("NewClientApp() error = %v", err) } refresher := NewRefresher(clientApp) // Test that SetUISessionStore doesn't panic with nil // Full mock implementation requires implementing the interface refresher.SetUISessionStore(nil) // Verify nil is accepted if refresher.uiSessionStore != nil { t.Error("Expected UI session store to be nil after setting nil") } } // ---------------------------------------------------------------------------- // Refresh-cancellation regression tests // ---------------------------------------------------------------------------- func TestIsSessionInvalidError(t *testing.T) { tests := []struct { name string err error want bool }{ {"nil", nil, false}, {"plain canceled", context.Canceled, false}, {"wrapped canceled", fmt.Errorf("token refresh failed: %w", context.Canceled), false}, {"wrapped deadline", fmt.Errorf("fetch: %w", context.DeadlineExceeded), false}, // Even if the message mentions an auth string, cancellation wins. {"canceled with auth-ish text", fmt.Errorf("invalid_grant: %w", context.Canceled), false}, {"api error 401", &atclient.APIError{StatusCode: 401}, true}, {"api error InvalidGrant", &atclient.APIError{StatusCode: 400, Name: "InvalidGrant"}, true}, {"api error InvalidToken", &atclient.APIError{StatusCode: 400, Name: "InvalidToken"}, true}, {"api error 500", &atclient.APIError{StatusCode: 500, Name: "InternalServerError"}, false}, // ExpiredToken means "refresh me", not "revoked". Treating it as a dead // session signs the user out of every UI session over an ordinary // access-token expiry that a refresh would have fixed. {"api error ExpiredToken is refreshable, not dead", &atclient.APIError{StatusCode: 400, Name: "ExpiredToken"}, false}, // Transient upstream failures must never evict: these are the shapes the // service-token path now wraps as APIErrors. {"api error 502", &atclient.APIError{StatusCode: 502, Name: ""}, false}, {"api error 429", &atclient.APIError{StatusCode: 429, Name: ""}, false}, {"api error 500 html body", &atclient.APIError{StatusCode: 500, Name: "", Message: "bad gateway"}, false}, // A revoked session reported as 401 with an atproto name — the case the // service-token path was previously flattening into an unmatchable string. {"api error 401 InvalidToken", &atclient.APIError{StatusCode: 401, Name: "InvalidToken"}, true}, // The refresh-replay failure arrives as a plain wrapped string from indigo. {"plain invalid_grant string", errors.New("failed to refresh OAuth tokens: token refresh failed (HTTP 400): invalid_grant"), true}, {"plain invalid_token string", errors.New("auth server request failed (HTTP 401): invalid_token"), true}, {"connection refused", errors.New(`Post "https://pds.example.com/oauth/token": dial tcp: connection refused`), false}, {"generic 500", errors.New("token refresh failed (HTTP 500): server exploded"), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := IsSessionInvalidError(tt.err); got != tt.want { t.Errorf("IsSessionInvalidError(%v) = %v, want %v", tt.err, got, tt.want) } }) } }