Upload images to your PDS and get instant CDN URLs via images.blue

add unit tests, general cleanup

evan.jarrett.net c6f0e625 37a584a6

verified
+37 -9
cmd/blup/main.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "io" 8 9 "log/slog" ··· 14 15 15 16 "github.com/bluesky-social/indigo/api/atproto" 16 17 "github.com/bluesky-social/indigo/atproto/atclient" 17 - lex_util "github.com/bluesky-social/indigo/lex/util" 18 + "github.com/bluesky-social/indigo/atproto/syntax" 18 19 "github.com/spf13/cobra" 19 20 "tangled.sh/evan.jarrett.net/blup/internal/auth" 20 21 "tangled.sh/evan.jarrett.net/blup/internal/clipboard" 21 22 "tangled.sh/evan.jarrett.net/blup/internal/screenshot" 22 - "tangled.sh/evan.jarrett.net/blup/internal/util" 23 + "tangled.sh/evan.jarrett.net/blup/internal/ui" 23 24 ) 24 25 25 26 const ( ··· 159 160 } 160 161 161 162 func runCapture(cmd *cobra.Command, args []string) error { 163 + // Pre-check auth in non-interactive mode BEFORE taking screenshot. 164 + // This prevents the confusing GNOME "screenshot captured" notification 165 + // when upload will fail anyway due to missing auth. 166 + if !ui.IsInteractive() && !auth.HasSavedCredentials() { 167 + ui.NotifyLoginRequired() 168 + return auth.ErrNoLoginIdentifier 169 + } 170 + 162 171 // Take screenshot using XDG Desktop Portal 163 172 fmt.Println("Opening screenshot dialog...") 164 173 imagePath, err := screenshot.CaptureScreenshot(true) ··· 176 185 // Copy to clipboard using wl-copy 177 186 if err := copyToClipboard(url); err != nil { 178 187 fmt.Fprintf(os.Stderr, "Warning: failed to copy to clipboard: %v\n", err) 188 + if !ui.IsInteractive() { 189 + ui.NotifyError("Failed to copy URL to clipboard") 190 + } 179 191 } 180 192 181 193 fmt.Println(url) ··· 228 240 229 241 // Get authenticated session (will re-auth if needed using saved login identifier) 230 242 sess, err := auth.RefreshTokens("") 243 + if errors.Is(err, auth.ErrNoLoginIdentifier) { 244 + if !ui.IsInteractive() { 245 + // Non-interactive mode (keyboard shortcut) - show notification and fail 246 + ui.NotifyLoginRequired() 247 + return "", auth.ErrNoLoginIdentifier 248 + } 249 + // Interactive mode - prompt for login 250 + fmt.Print("Enter your ATProto handle: ") 251 + var handle string 252 + fmt.Scanln(&handle) 253 + handle = strings.TrimPrefix(handle, "@") 254 + sess, err = auth.RefreshTokens(handle) 255 + } 231 256 if err != nil { 232 - return "", fmt.Errorf("not authenticated, run '%s login' first: %w", Name, err) 257 + if !ui.IsInteractive() { 258 + ui.NotifyError(err.Error()) 259 + } 260 + return "", fmt.Errorf("authentication failed: %w", err) 233 261 } 234 262 235 263 // Get API client from session ··· 295 323 return "", fmt.Errorf("failed to create record: %w", err) 296 324 } 297 325 298 - // Extract blob CID for server URL 299 - blob := record["blob"].(*lex_util.LexBlob) 300 - blobCID := blob.Ref.String() 301 - converted, err := util.ConvertCIDBase32ToBase62(blobCID) 326 + // Extract rkey from the record URI for shorter URLs 327 + // URI format: at://did:plc:xxx/blue.imgs.blup.image/{rkey} 328 + uri, err := syntax.ParseATURI(recordOut.Uri) 302 329 if err != nil { 303 - return "", err 330 + return "", fmt.Errorf("failed to parse record URI: %w", err) 304 331 } 332 + rkey := uri.RecordKey().String() 305 333 306 - return fmt.Sprintf("%s/%s/%s", CDN, handle, converted), nil 334 + return fmt.Sprintf("%s/%s/%s", CDN, handle, rkey), nil 307 335 } 308 336 309 337 func setupLogging() {
+4 -6
go.mod
··· 4 4 5 5 require ( 6 6 github.com/bluesky-social/indigo v0.0.0-20251223190123-598fbf0e146e 7 - //github.com/gen2brain/beeep v0.11.1 8 - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 7 + github.com/godbus/dbus/v5 v5.1.0 9 8 github.com/ipfs/go-cid v0.5.0 10 9 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c 11 10 github.com/spf13/cobra v1.9.1 12 11 github.com/zalando/go-keyring v0.2.6 13 - //golang.design/x/clipboard v0.7.1 12 + golang.org/x/term v0.38.0 14 13 ) 15 - 16 - require github.com/godbus/dbus/v5 v5.1.0 17 14 18 15 require ( 19 16 al.essio.dev/pkg/shellescape v1.6.0 // indirect ··· 22 19 github.com/danieljoos/wincred v1.2.2 // indirect 23 20 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 24 21 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 22 + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 25 23 github.com/google/go-querystring v1.1.0 // indirect 26 24 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 27 25 github.com/inconshreveable/mousetrap v1.1.0 // indirect ··· 45 43 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 46 44 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 47 45 golang.org/x/crypto v0.39.0 // indirect 48 - golang.org/x/sys v0.33.0 // indirect 46 + golang.org/x/sys v0.39.0 // indirect 49 47 golang.org/x/time v0.8.0 // indirect 50 48 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 51 49 google.golang.org/protobuf v1.36.6 // indirect
+4 -2
go.sum
··· 82 82 golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= 83 83 golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 84 84 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 - golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 86 - golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 85 + golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 86 + golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 87 + golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= 88 + golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 87 89 golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 88 90 golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 89 91 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+31
internal/auth/keyring.go
··· 1 + package auth 2 + 3 + import ( 4 + "github.com/zalando/go-keyring" 5 + ) 6 + 7 + // Keyring defines the interface for keyring operations. 8 + // This allows mocking the system keyring for testing. 9 + type Keyring interface { 10 + Get(service, key string) (string, error) 11 + Set(service, key, value string) error 12 + Delete(service, key string) error 13 + } 14 + 15 + // RealKeyring implements Keyring using the system keyring. 16 + type RealKeyring struct{} 17 + 18 + func (r *RealKeyring) Get(service, key string) (string, error) { 19 + return keyring.Get(service, key) 20 + } 21 + 22 + func (r *RealKeyring) Set(service, key, value string) error { 23 + return keyring.Set(service, key, value) 24 + } 25 + 26 + func (r *RealKeyring) Delete(service, key string) error { 27 + return keyring.Delete(service, key) 28 + } 29 + 30 + // DefaultKeyring is the default keyring implementation. 31 + var DefaultKeyring Keyring = &RealKeyring{}
+119
internal/auth/metadata_test.go
··· 1 + package auth 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestGetClientMetadata(t *testing.T) { 8 + metadata := GetClientMetadata() 9 + 10 + // Verify required fields are populated 11 + if metadata.ClientID == "" { 12 + t.Error("ClientID should not be empty") 13 + } 14 + if metadata.ClientName == "" { 15 + t.Error("ClientName should not be empty") 16 + } 17 + if metadata.ClientURI == "" { 18 + t.Error("ClientURI should not be empty") 19 + } 20 + if len(metadata.RedirectURIs) == 0 { 21 + t.Error("RedirectURIs should not be empty") 22 + } 23 + if len(metadata.GrantTypes) == 0 { 24 + t.Error("GrantTypes should not be empty") 25 + } 26 + if metadata.Scope == "" { 27 + t.Error("Scope should not be empty") 28 + } 29 + 30 + // Verify expected values from client-metadata.json 31 + if metadata.ClientID != "https://blup.imgs.blue/oauth-client-metadata.json" { 32 + t.Errorf("ClientID = %q, want %q", metadata.ClientID, "https://blup.imgs.blue/oauth-client-metadata.json") 33 + } 34 + if metadata.ClientName != "Blup" { 35 + t.Errorf("ClientName = %q, want %q", metadata.ClientName, "Blup") 36 + } 37 + if metadata.ClientURI != "https://blup.imgs.blue" { 38 + t.Errorf("ClientURI = %q, want %q", metadata.ClientURI, "https://blup.imgs.blue") 39 + } 40 + if metadata.RedirectURIs[0] != "https://blup.imgs.blue/oauth/callback" { 41 + t.Errorf("RedirectURIs[0] = %q, want %q", metadata.RedirectURIs[0], "https://blup.imgs.blue/oauth/callback") 42 + } 43 + if !metadata.DpopBoundAccessTokens { 44 + t.Error("DpopBoundAccessTokens should be true") 45 + } 46 + if metadata.TokenEndpointAuthMethod != "none" { 47 + t.Errorf("TokenEndpointAuthMethod = %q, want %q", metadata.TokenEndpointAuthMethod, "none") 48 + } 49 + if metadata.ApplicationType != "native" { 50 + t.Errorf("ApplicationType = %q, want %q", metadata.ApplicationType, "native") 51 + } 52 + } 53 + 54 + func TestGetClientMetadataGrantTypes(t *testing.T) { 55 + metadata := GetClientMetadata() 56 + 57 + expectedGrantTypes := []string{"authorization_code", "refresh_token"} 58 + if len(metadata.GrantTypes) != len(expectedGrantTypes) { 59 + t.Fatalf("GrantTypes length = %d, want %d", len(metadata.GrantTypes), len(expectedGrantTypes)) 60 + } 61 + 62 + for i, gt := range expectedGrantTypes { 63 + if metadata.GrantTypes[i] != gt { 64 + t.Errorf("GrantTypes[%d] = %q, want %q", i, metadata.GrantTypes[i], gt) 65 + } 66 + } 67 + } 68 + 69 + func TestGetClientMetadataResponseTypes(t *testing.T) { 70 + metadata := GetClientMetadata() 71 + 72 + if len(metadata.ResponseTypes) != 1 { 73 + t.Fatalf("ResponseTypes length = %d, want 1", len(metadata.ResponseTypes)) 74 + } 75 + if metadata.ResponseTypes[0] != "code" { 76 + t.Errorf("ResponseTypes[0] = %q, want %q", metadata.ResponseTypes[0], "code") 77 + } 78 + } 79 + 80 + func TestGetClientConfig(t *testing.T) { 81 + config := GetClientConfig() 82 + 83 + // Verify the config is built correctly 84 + if config.ClientID == "" { 85 + t.Error("ClientID should not be empty") 86 + } 87 + if config.CallbackURL == "" { 88 + t.Error("CallbackURL should not be empty") 89 + } 90 + if len(config.Scopes) == 0 { 91 + t.Error("Scopes should not be empty") 92 + } 93 + 94 + // Verify expected values 95 + if config.ClientID != "https://blup.imgs.blue/oauth-client-metadata.json" { 96 + t.Errorf("ClientID = %q, want %q", config.ClientID, "https://blup.imgs.blue/oauth-client-metadata.json") 97 + } 98 + if config.CallbackURL != "https://blup.imgs.blue/oauth/callback" { 99 + t.Errorf("CallbackURL = %q, want %q", config.CallbackURL, "https://blup.imgs.blue/oauth/callback") 100 + } 101 + } 102 + 103 + func TestGetClientConfigScopes(t *testing.T) { 104 + config := GetClientConfig() 105 + 106 + // Scope in metadata is "atproto repo:blue.imgs.blup.image blob:image/*" 107 + // Should be split into 3 scopes 108 + expectedScopes := []string{"atproto", "repo:blue.imgs.blup.image", "blob:image/*"} 109 + 110 + if len(config.Scopes) != len(expectedScopes) { 111 + t.Fatalf("Scopes length = %d, want %d; got %v", len(config.Scopes), len(expectedScopes), config.Scopes) 112 + } 113 + 114 + for i, scope := range expectedScopes { 115 + if config.Scopes[i] != scope { 116 + t.Errorf("Scopes[%d] = %q, want %q", i, config.Scopes[i], scope) 117 + } 118 + } 119 + }
+171 -81
internal/auth/oauth.go
··· 4 4 "bufio" 5 5 "context" 6 6 "encoding/json" 7 + "errors" 7 8 "fmt" 8 9 "log/slog" 9 10 "net/http" ··· 17 18 "github.com/pkg/browser" 18 19 ) 19 20 21 + // ErrNoLoginIdentifier is returned when no active session exists and no login identifier 22 + // is saved. Callers should prompt the user for their handle and retry. 23 + var ErrNoLoginIdentifier = errors.New("no active session and no login identifier provided") 24 + 25 + // SSEAuthData holds the parsed auth completion data from SSE events. 26 + type SSEAuthData struct { 27 + Code string `json:"code"` 28 + Iss string `json:"iss"` 29 + State string `json:"state"` 30 + } 31 + 32 + // parseSSEAuthData parses the JSON data from an SSE auth-complete event. 33 + func parseSSEAuthData(data string) (*SSEAuthData, error) { 34 + data = strings.TrimSpace(data) 35 + if data == "" { 36 + return nil, fmt.Errorf("empty auth data") 37 + } 38 + 39 + var authData SSEAuthData 40 + if err := json.Unmarshal([]byte(data), &authData); err != nil { 41 + return nil, fmt.Errorf("failed to parse auth data: %w", err) 42 + } 43 + 44 + if authData.Code == "" || authData.Iss == "" || authData.State == "" { 45 + return nil, fmt.Errorf("missing required fields in auth data") 46 + } 47 + 48 + return &authData, nil 49 + } 50 + 51 + // NewClientApp creates a new OAuth client app and keyring store. 52 + // Optionally accepts a KeyringAuthStore for testing; if not provided, creates a real one. 53 + func NewClientApp(stores ...*KeyringAuthStore) (*oauth.ClientApp, *KeyringAuthStore) { 54 + var store *KeyringAuthStore 55 + if len(stores) > 0 && stores[0] != nil { 56 + store = stores[0] 57 + } else { 58 + store = NewKeyringAuthStore() 59 + } 60 + clientConfig := GetClientConfig() 61 + app := oauth.NewClientApp(&clientConfig, store) 62 + return app, store 63 + } 64 + 65 + // AuthenticateAndResume performs a full OAuth authentication flow and returns a resumed session. 66 + func AuthenticateAndResume(ctx context.Context, loginIdentifier string) (*oauth.ClientSession, error) { 67 + flow, err := NewOAuthFlow(loginIdentifier) 68 + if err != nil { 69 + return nil, err 70 + } 71 + sess, err := flow.Authenticate() 72 + if err != nil { 73 + return nil, err 74 + } 75 + app, _ := NewClientApp() 76 + return app.ResumeSession(ctx, sess.AccountDID, sess.SessionID) 77 + } 78 + 79 + // ResumeCurrentSession retrieves the current session from the store and resumes it. 80 + // Returns the session, session data, and any error. 81 + func ResumeCurrentSession(ctx context.Context) (*oauth.ClientSession, *oauth.ClientSessionData, error) { 82 + app, store := NewClientApp() 83 + sessData, err := store.GetCurrentSession(ctx) 84 + if err != nil { 85 + return nil, nil, err 86 + } 87 + sess, err := app.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID) 88 + if err != nil { 89 + return nil, sessData, err 90 + } 91 + return sess, sessData, nil 92 + } 93 + 94 + // HTTPDoer abstracts HTTP client operations for testing. 95 + type HTTPDoer interface { 96 + Do(req *http.Request) (*http.Response, error) 97 + } 98 + 99 + // BrowserOpener is a function that opens a URL in the browser. 100 + type BrowserOpener func(url string) error 101 + 20 102 type OAuthFlow struct { 21 103 app *oauth.ClientApp 22 104 loginIdentifier string ··· 24 106 authSuccess chan *oauth.ClientSessionData 25 107 authError chan error 26 108 savedState string 109 + 110 + // Injectable dependencies (nil means use defaults) 111 + httpClient HTTPDoer 112 + openBrowser BrowserOpener 27 113 } 28 114 29 - func NewOAuthFlow(loginIdentifier string) (*OAuthFlow, error) { 30 - store := NewKeyringAuthStore() 115 + // OAuthFlowOption configures an OAuthFlow. 116 + type OAuthFlowOption func(*OAuthFlow) 117 + 118 + // WithHTTPClient sets a custom HTTP client for the OAuth flow. 119 + func WithHTTPClient(client HTTPDoer) OAuthFlowOption { 120 + return func(f *OAuthFlow) { 121 + f.httpClient = client 122 + } 123 + } 124 + 125 + // WithBrowserOpener sets a custom browser opener for the OAuth flow. 126 + func WithBrowserOpener(opener BrowserOpener) OAuthFlowOption { 127 + return func(f *OAuthFlow) { 128 + f.openBrowser = opener 129 + } 130 + } 131 + 132 + // WithStore sets a custom keyring store for the OAuth flow. 133 + func WithStore(store *KeyringAuthStore) OAuthFlowOption { 134 + return func(f *OAuthFlow) { 135 + f.store = store 136 + } 137 + } 138 + 139 + func NewOAuthFlow(loginIdentifier string, opts ...OAuthFlowOption) (*OAuthFlow, error) { 140 + flow := &OAuthFlow{ 141 + loginIdentifier: loginIdentifier, 142 + authSuccess: make(chan *oauth.ClientSessionData, 1), 143 + authError: make(chan error, 1), 144 + } 145 + 146 + // Apply options 147 + for _, opt := range opts { 148 + opt(flow) 149 + } 150 + 151 + // Set defaults for nil dependencies 152 + if flow.store == nil { 153 + flow.store = NewKeyringAuthStore() 154 + } 155 + if flow.httpClient == nil { 156 + flow.httpClient = &http.Client{Timeout: 5 * time.Minute} 157 + } 158 + if flow.openBrowser == nil { 159 + flow.openBrowser = browser.OpenURL 160 + } 161 + 31 162 clientConfig := GetClientConfig() 32 163 33 164 // Debug: show what we're requesting ··· 39 170 "scope_raw", metadata.Scope, 40 171 ) 41 172 42 - app := oauth.NewClientApp(&clientConfig, store) 173 + flow.app = oauth.NewClientApp(&clientConfig, flow.store) 43 174 44 - return &OAuthFlow{ 45 - app: app, 46 - loginIdentifier: loginIdentifier, 47 - store: store, 48 - authSuccess: make(chan *oauth.ClientSessionData, 1), 49 - authError: make(chan error, 1), 50 - }, nil 175 + return flow, nil 51 176 } 52 177 53 178 func (f *OAuthFlow) Authenticate() (*oauth.ClientSessionData, error) { ··· 75 200 76 201 // Open browser to authorization URL 77 202 fmt.Printf("Opening browser for authentication...\n") 78 - if err := browser.OpenURL(redirectURL); err != nil { 203 + if err := f.openBrowser(redirectURL); err != nil { 79 204 fmt.Printf("Failed to open browser automatically.\n") 80 205 fmt.Printf("Please open this URL manually:\n%s\n", redirectURL) 81 206 } ··· 119 244 req.Header.Set("Accept", "text/event-stream") 120 245 req.Header.Set("Cache-Control", "no-cache") 121 246 122 - client := &http.Client{Timeout: 5 * time.Minute} 123 - resp, err := client.Do(req) 247 + resp, err := f.httpClient.Do(req) 124 248 if err != nil { 125 249 slog.Debug("SSE connection error", "error", err) 126 250 f.authError <- err ··· 149 273 dataLine, _ := reader.ReadString('\n') 150 274 slog.Debug("SSE auth data", "data", dataLine) 151 275 if after, ok := strings.CutPrefix(dataLine, "data: "); ok { 152 - data := after 153 - var authData struct { 154 - Code string `json:"code"` 155 - Iss string `json:"iss"` 156 - State string `json:"state"` 157 - } 158 - if err := json.Unmarshal([]byte(data), &authData); err != nil { 159 - f.authError <- fmt.Errorf("failed to parse auth data: %w", err) 276 + authData, err := parseSSEAuthData(after) 277 + if err != nil { 278 + f.authError <- err 160 279 return 161 280 } 162 281 slog.Debug("SSE auth complete", "iss", authData.Iss, "state", authData.State) ··· 201 320 202 321 // GetSession retrieves the current session, refreshing tokens if needed 203 322 func GetSession() (*oauth.ClientSession, error) { 204 - ctx := context.Background() 205 - store := NewKeyringAuthStore() 206 - 207 - // Check for current session 208 - sessData, err := store.GetCurrentSession(ctx) 209 - if err != nil { 210 - return nil, fmt.Errorf("no active session") 211 - } 212 - 213 - clientConfig := GetClientConfig() 214 - app := oauth.NewClientApp(&clientConfig, store) 215 - 216 - // Resume the session 217 - sess, err := app.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID) 323 + sess, _, err := ResumeCurrentSession(context.Background()) 218 324 if err != nil { 219 325 return nil, fmt.Errorf("failed to resume session: %w", err) 220 326 } 221 - 222 327 return sess, nil 223 328 } 224 329 ··· 227 332 // If empty, it will use the saved login identifier from keyring 228 333 func RefreshTokens(loginIdentifier string) (*oauth.ClientSession, error) { 229 334 ctx := context.Background() 230 - store := NewKeyringAuthStore() 231 335 232 - // Check for current session 233 - sessData, err := store.GetCurrentSession(ctx) 234 - if err != nil { 235 - // No session, need to authenticate 236 - if loginIdentifier == "" { 237 - return nil, fmt.Errorf("no active session and no login identifier provided") 238 - } 239 - flow, err := NewOAuthFlow(loginIdentifier) 240 - if err != nil { 241 - return nil, err 242 - } 243 - sess, err := flow.Authenticate() 336 + // Try to resume current session 337 + sess, _, err := ResumeCurrentSession(ctx) 338 + if err == nil { 339 + return sess, nil 340 + } 341 + 342 + // Session doesn't exist or failed to resume - need to authenticate 343 + // Get login identifier from keyring if not provided 344 + if loginIdentifier == "" { 345 + _, store := NewClientApp() 346 + loginIdentifier, err = store.GetLoginIdentifier() 244 347 if err != nil { 245 - return nil, err 348 + return nil, ErrNoLoginIdentifier 246 349 } 247 - // Resume the session to return a ClientSession 248 - clientConfig := GetClientConfig() 249 - app := oauth.NewClientApp(&clientConfig, store) 250 - return app.ResumeSession(ctx, sess.AccountDID, sess.SessionID) 251 350 } 252 351 253 - clientConfig := GetClientConfig() 254 - app := oauth.NewClientApp(&clientConfig, store) 352 + return AuthenticateAndResume(ctx, loginIdentifier) 353 + } 354 + 355 + // HasSavedCredentials returns true if there's a saved session or login identifier. 356 + // This is a lightweight check that doesn't attempt authentication. 357 + func HasSavedCredentials() bool { 358 + ctx := context.Background() 359 + store := NewKeyringAuthStore() 360 + 361 + // Check for existing session 362 + if _, err := store.GetCurrentSession(ctx); err == nil { 363 + return true 364 + } 255 365 256 - // Resume the session - this will auto-refresh tokens on 401 257 - sess, err := app.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID) 258 - if err != nil { 259 - // Session invalid, need to re-authenticate 260 - // Get login identifier from keyring if not provided 261 - if loginIdentifier == "" { 262 - loginIdentifier, err = store.GetLoginIdentifier() 263 - if err != nil { 264 - return nil, fmt.Errorf("session expired and no saved login identifier: %w", err) 265 - } 266 - } 267 - flow, err := NewOAuthFlow(loginIdentifier) 268 - if err != nil { 269 - return nil, err 270 - } 271 - newSess, err := flow.Authenticate() 272 - if err != nil { 273 - return nil, err 274 - } 275 - return app.ResumeSession(ctx, newSess.AccountDID, newSess.SessionID) 366 + // Check for saved login identifier (can be used to re-authenticate) 367 + if _, err := store.GetLoginIdentifier(); err == nil { 368 + return true 276 369 } 277 370 278 - return sess, nil 371 + return false 279 372 } 280 373 281 374 // Logout revokes tokens and clears the session 282 375 func Logout() error { 283 376 ctx := context.Background() 284 - store := NewKeyringAuthStore() 377 + app, store := NewClientApp() 285 378 286 379 sessData, err := store.GetCurrentSession(ctx) 287 380 if err != nil { 288 381 // No session to logout 289 382 return nil 290 383 } 291 - 292 - clientConfig := GetClientConfig() 293 - app := oauth.NewClientApp(&clientConfig, store) 294 384 295 385 // Logout revokes tokens and deletes session 296 386 if err := app.Logout(ctx, sessData.AccountDID, sessData.SessionID); err != nil {
+246
internal/auth/oauth_test.go
··· 1 + package auth 2 + 3 + import ( 4 + "net/http" 5 + "testing" 6 + ) 7 + 8 + func TestNewOAuthFlow(t *testing.T) { 9 + flow, err := NewOAuthFlow("test.bsky.social") 10 + if err != nil { 11 + t.Fatalf("NewOAuthFlow() error = %v", err) 12 + } 13 + 14 + if flow == nil { 15 + t.Fatal("NewOAuthFlow() returned nil") 16 + } 17 + 18 + if flow.loginIdentifier != "test.bsky.social" { 19 + t.Errorf("loginIdentifier = %q, want %q", flow.loginIdentifier, "test.bsky.social") 20 + } 21 + 22 + if flow.app == nil { 23 + t.Error("app should not be nil") 24 + } 25 + 26 + if flow.store == nil { 27 + t.Error("store should not be nil") 28 + } 29 + 30 + if flow.authSuccess == nil { 31 + t.Error("authSuccess channel should not be nil") 32 + } 33 + 34 + if flow.authError == nil { 35 + t.Error("authError channel should not be nil") 36 + } 37 + 38 + if flow.httpClient == nil { 39 + t.Error("httpClient should not be nil (default)") 40 + } 41 + 42 + if flow.openBrowser == nil { 43 + t.Error("openBrowser should not be nil (default)") 44 + } 45 + } 46 + 47 + func TestNewOAuthFlowEmptyIdentifier(t *testing.T) { 48 + flow, err := NewOAuthFlow("") 49 + if err != nil { 50 + t.Fatalf("NewOAuthFlow() error = %v", err) 51 + } 52 + 53 + if flow == nil { 54 + t.Fatal("NewOAuthFlow() returned nil") 55 + } 56 + 57 + if flow.loginIdentifier != "" { 58 + t.Errorf("loginIdentifier = %q, want empty string", flow.loginIdentifier) 59 + } 60 + } 61 + 62 + func TestAuthenticateRequiresIdentifier(t *testing.T) { 63 + flow, err := NewOAuthFlow("") 64 + if err != nil { 65 + t.Fatalf("NewOAuthFlow() error = %v", err) 66 + } 67 + 68 + _, err = flow.Authenticate() 69 + if err == nil { 70 + t.Error("Authenticate() expected error with empty identifier, got nil") 71 + } 72 + 73 + expectedErr := "login identifier is required" 74 + if err.Error() != expectedErr { 75 + t.Errorf("Authenticate() error = %q, want %q", err.Error(), expectedErr) 76 + } 77 + } 78 + 79 + // MockHTTPClient implements HTTPDoer for testing 80 + type MockHTTPClient struct { 81 + DoFunc func(req *http.Request) (*http.Response, error) 82 + } 83 + 84 + func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) { 85 + return m.DoFunc(req) 86 + } 87 + 88 + func TestNewOAuthFlowWithOptions(t *testing.T) { 89 + mockStore := NewKeyringAuthStoreWithKeyring(NewMockKeyring()) 90 + mockHTTP := &MockHTTPClient{} 91 + browserCalled := false 92 + mockBrowser := func(url string) error { 93 + browserCalled = true 94 + return nil 95 + } 96 + 97 + flow, err := NewOAuthFlow("test.bsky.social", 98 + WithStore(mockStore), 99 + WithHTTPClient(mockHTTP), 100 + WithBrowserOpener(mockBrowser), 101 + ) 102 + if err != nil { 103 + t.Fatalf("NewOAuthFlow() error = %v", err) 104 + } 105 + 106 + if flow.store != mockStore { 107 + t.Error("store was not injected correctly") 108 + } 109 + 110 + if flow.httpClient != mockHTTP { 111 + t.Error("httpClient was not injected correctly") 112 + } 113 + 114 + // Test browser opener was injected 115 + flow.openBrowser("http://test.com") 116 + if !browserCalled { 117 + t.Error("openBrowser was not injected correctly") 118 + } 119 + } 120 + 121 + func TestNewClientAppWithStore(t *testing.T) { 122 + mockKeyring := NewMockKeyring() 123 + mockStore := NewKeyringAuthStoreWithKeyring(mockKeyring) 124 + 125 + app, store := NewClientApp(mockStore) 126 + 127 + if app == nil { 128 + t.Error("NewClientApp() app should not be nil") 129 + } 130 + 131 + if store != mockStore { 132 + t.Error("NewClientApp() should return the injected store") 133 + } 134 + } 135 + 136 + func TestNewClientAppWithoutStore(t *testing.T) { 137 + app, store := NewClientApp() 138 + 139 + if app == nil { 140 + t.Error("NewClientApp() app should not be nil") 141 + } 142 + 143 + if store == nil { 144 + t.Error("NewClientApp() should create a default store") 145 + } 146 + } 147 + 148 + func TestParseSSEAuthData(t *testing.T) { 149 + tests := []struct { 150 + name string 151 + input string 152 + wantErr bool 153 + code string 154 + iss string 155 + state string 156 + }{ 157 + { 158 + name: "valid auth data", 159 + input: `{"code":"auth_code_123","iss":"https://bsky.social","state":"state_abc"}`, 160 + wantErr: false, 161 + code: "auth_code_123", 162 + iss: "https://bsky.social", 163 + state: "state_abc", 164 + }, 165 + { 166 + name: "valid with whitespace", 167 + input: ` {"code":"code","iss":"iss","state":"state"} `, 168 + wantErr: false, 169 + code: "code", 170 + iss: "iss", 171 + state: "state", 172 + }, 173 + { 174 + name: "empty string", 175 + input: "", 176 + wantErr: true, 177 + }, 178 + { 179 + name: "whitespace only", 180 + input: " ", 181 + wantErr: true, 182 + }, 183 + { 184 + name: "invalid JSON", 185 + input: "not json", 186 + wantErr: true, 187 + }, 188 + { 189 + name: "missing code", 190 + input: `{"iss":"https://bsky.social","state":"state_abc"}`, 191 + wantErr: true, 192 + }, 193 + { 194 + name: "missing iss", 195 + input: `{"code":"auth_code_123","state":"state_abc"}`, 196 + wantErr: true, 197 + }, 198 + { 199 + name: "missing state", 200 + input: `{"code":"auth_code_123","iss":"https://bsky.social"}`, 201 + wantErr: true, 202 + }, 203 + { 204 + name: "empty code", 205 + input: `{"code":"","iss":"https://bsky.social","state":"state_abc"}`, 206 + wantErr: true, 207 + }, 208 + } 209 + 210 + for _, tt := range tests { 211 + t.Run(tt.name, func(t *testing.T) { 212 + result, err := parseSSEAuthData(tt.input) 213 + 214 + if tt.wantErr { 215 + if err == nil { 216 + t.Errorf("parseSSEAuthData() expected error, got nil") 217 + } 218 + return 219 + } 220 + 221 + if err != nil { 222 + t.Errorf("parseSSEAuthData() unexpected error: %v", err) 223 + return 224 + } 225 + 226 + if result.Code != tt.code { 227 + t.Errorf("Code = %q, want %q", result.Code, tt.code) 228 + } 229 + if result.Iss != tt.iss { 230 + t.Errorf("Iss = %q, want %q", result.Iss, tt.iss) 231 + } 232 + if result.State != tt.state { 233 + t.Errorf("State = %q, want %q", result.State, tt.state) 234 + } 235 + }) 236 + } 237 + } 238 + 239 + func TestLogoutNoSession(t *testing.T) { 240 + // Logout should succeed even when no session exists 241 + // Note: This uses the real keyring, but should still work 242 + // because it handles the "no session" case gracefully 243 + err := Logout() 244 + // We can't easily test this without mocking, but we can verify it doesn't panic 245 + _ = err 246 + }
+26 -18
internal/auth/storage.go
··· 7 7 8 8 "github.com/bluesky-social/indigo/atproto/auth/oauth" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - "github.com/zalando/go-keyring" 11 10 ) 12 11 13 12 const ( ··· 20 19 ) 21 20 22 21 // KeyringAuthStore implements oauth.ClientAuthStore using the system keyring 23 - type KeyringAuthStore struct{} 22 + type KeyringAuthStore struct { 23 + keyring Keyring 24 + } 24 25 26 + // NewKeyringAuthStore creates a new KeyringAuthStore using the system keyring. 25 27 func NewKeyringAuthStore() *KeyringAuthStore { 26 - return &KeyringAuthStore{} 28 + return &KeyringAuthStore{keyring: DefaultKeyring} 29 + } 30 + 31 + // NewKeyringAuthStoreWithKeyring creates a KeyringAuthStore with a custom Keyring implementation. 32 + // This is useful for testing with a mock keyring. 33 + func NewKeyringAuthStoreWithKeyring(kr Keyring) *KeyringAuthStore { 34 + return &KeyringAuthStore{keyring: kr} 27 35 } 28 36 29 37 // sessionKey creates the keyring key for a session ··· 33 41 34 42 // GetSession retrieves a session from the keyring 35 43 func (s *KeyringAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 36 - data, err := keyring.Get(keyringService, sessionKey(did, sessionID)) 44 + data, err := s.keyring.Get(keyringService, sessionKey(did, sessionID)) 37 45 if err != nil { 38 46 return nil, err 39 47 } ··· 53 61 return err 54 62 } 55 63 56 - return keyring.Set(keyringService, sessionKey(sess.AccountDID, sess.SessionID), string(data)) 64 + return s.keyring.Set(keyringService, sessionKey(sess.AccountDID, sess.SessionID), string(data)) 57 65 } 58 66 59 67 // DeleteSession removes a session from the keyring 60 68 func (s *KeyringAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 61 - return keyring.Delete(keyringService, sessionKey(did, sessionID)) 69 + return s.keyring.Delete(keyringService, sessionKey(did, sessionID)) 62 70 } 63 71 64 72 // authRequestKey creates the keyring key for an auth request ··· 68 76 69 77 // GetAuthRequestInfo retrieves pending auth request info 70 78 func (s *KeyringAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 71 - data, err := keyring.Get(keyringService, authRequestKey(state)) 79 + data, err := s.keyring.Get(keyringService, authRequestKey(state)) 72 80 if err != nil { 73 81 return nil, err 74 82 } ··· 89 97 } 90 98 91 99 // Save the auth request data 92 - if err := keyring.Set(keyringService, authRequestKey(info.State), string(data)); err != nil { 100 + if err := s.keyring.Set(keyringService, authRequestKey(info.State), string(data)); err != nil { 93 101 return err 94 102 } 95 103 96 104 // Also save the state as the current pending auth (for SSE correlation) 97 - return keyring.Set(keyringService, pendingAuthStateKey, info.State) 105 + return s.keyring.Set(keyringService, pendingAuthStateKey, info.State) 98 106 } 99 107 100 108 // GetPendingAuthState returns the state of the current pending auth request 101 109 func (s *KeyringAuthStore) GetPendingAuthState() (string, error) { 102 - return keyring.Get(keyringService, pendingAuthStateKey) 110 + return s.keyring.Get(keyringService, pendingAuthStateKey) 103 111 } 104 112 105 113 // ClearPendingAuthState removes the pending auth state 106 114 func (s *KeyringAuthStore) ClearPendingAuthState() error { 107 - return keyring.Delete(keyringService, pendingAuthStateKey) 115 + return s.keyring.Delete(keyringService, pendingAuthStateKey) 108 116 } 109 117 110 118 // DeleteAuthRequestInfo removes pending auth request info 111 119 func (s *KeyringAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 112 - return keyring.Delete(keyringService, authRequestKey(state)) 120 + return s.keyring.Delete(keyringService, authRequestKey(state)) 113 121 } 114 122 115 123 // CurrentSessionRef stores reference to the current active session ··· 120 128 121 129 // GetCurrentSession retrieves the current active session for the CLI 122 130 func (s *KeyringAuthStore) GetCurrentSession(ctx context.Context) (*oauth.ClientSessionData, error) { 123 - refData, err := keyring.Get(keyringService, currentSessionKey) 131 + refData, err := s.keyring.Get(keyringService, currentSessionKey) 124 132 if err != nil { 125 133 return nil, err 126 134 } ··· 150 158 return err 151 159 } 152 160 153 - return keyring.Set(keyringService, currentSessionKey, string(data)) 161 + return s.keyring.Set(keyringService, currentSessionKey, string(data)) 154 162 } 155 163 156 164 // ClearCurrentSession removes the current session reference 157 165 func (s *KeyringAuthStore) ClearCurrentSession() error { 158 - return keyring.Delete(keyringService, currentSessionKey) 166 + return s.keyring.Delete(keyringService, currentSessionKey) 159 167 } 160 168 161 169 // GetLoginIdentifier retrieves the stored login identifier (handle or PDS URL) 162 170 func (s *KeyringAuthStore) GetLoginIdentifier() (string, error) { 163 - return keyring.Get(keyringService, loginIdentifierKey) 171 + return s.keyring.Get(keyringService, loginIdentifierKey) 164 172 } 165 173 166 174 // SetLoginIdentifier stores the login identifier for re-authentication 167 175 func (s *KeyringAuthStore) SetLoginIdentifier(id string) error { 168 - return keyring.Set(keyringService, loginIdentifierKey, id) 176 + return s.keyring.Set(keyringService, loginIdentifierKey, id) 169 177 } 170 178 171 179 // ClearLoginIdentifier removes the stored login identifier 172 180 func (s *KeyringAuthStore) ClearLoginIdentifier() error { 173 - return keyring.Delete(keyringService, loginIdentifierKey) 181 + return s.keyring.Delete(keyringService, loginIdentifierKey) 174 182 }
+318
internal/auth/storage_test.go
··· 1 + package auth 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "testing" 7 + 8 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + // MockKeyring is an in-memory implementation of Keyring for testing. 13 + type MockKeyring struct { 14 + data map[string]map[string]string // service -> key -> value 15 + } 16 + 17 + func NewMockKeyring() *MockKeyring { 18 + return &MockKeyring{ 19 + data: make(map[string]map[string]string), 20 + } 21 + } 22 + 23 + var ErrNotFound = errors.New("secret not found in keyring") 24 + 25 + func (m *MockKeyring) Get(service, key string) (string, error) { 26 + if svc, ok := m.data[service]; ok { 27 + if val, ok := svc[key]; ok { 28 + return val, nil 29 + } 30 + } 31 + return "", ErrNotFound 32 + } 33 + 34 + func (m *MockKeyring) Set(service, key, value string) error { 35 + if _, ok := m.data[service]; !ok { 36 + m.data[service] = make(map[string]string) 37 + } 38 + m.data[service][key] = value 39 + return nil 40 + } 41 + 42 + func (m *MockKeyring) Delete(service, key string) error { 43 + if svc, ok := m.data[service]; ok { 44 + delete(svc, key) 45 + } 46 + return nil 47 + } 48 + 49 + func TestSessionKey(t *testing.T) { 50 + did, _ := syntax.ParseDID("did:plc:test123") 51 + key := sessionKey(did, "session-abc") 52 + expected := "session:did:plc:test123:session-abc" 53 + if key != expected { 54 + t.Errorf("sessionKey() = %q, want %q", key, expected) 55 + } 56 + } 57 + 58 + func TestAuthRequestKey(t *testing.T) { 59 + key := authRequestKey("state123") 60 + expected := "auth-request:state123" 61 + if key != expected { 62 + t.Errorf("authRequestKey() = %q, want %q", key, expected) 63 + } 64 + } 65 + 66 + func TestSaveAndGetSession(t *testing.T) { 67 + mock := NewMockKeyring() 68 + store := NewKeyringAuthStoreWithKeyring(mock) 69 + ctx := context.Background() 70 + 71 + did, _ := syntax.ParseDID("did:plc:testuser") 72 + sess := oauth.ClientSessionData{ 73 + AccountDID: did, 74 + SessionID: "test-session-id", 75 + } 76 + 77 + // Save session 78 + if err := store.SaveSession(ctx, sess); err != nil { 79 + t.Fatalf("SaveSession() error = %v", err) 80 + } 81 + 82 + // Get session 83 + retrieved, err := store.GetSession(ctx, did, "test-session-id") 84 + if err != nil { 85 + t.Fatalf("GetSession() error = %v", err) 86 + } 87 + 88 + if retrieved.AccountDID.String() != did.String() { 89 + t.Errorf("GetSession() DID = %q, want %q", retrieved.AccountDID.String(), did.String()) 90 + } 91 + if retrieved.SessionID != "test-session-id" { 92 + t.Errorf("GetSession() SessionID = %q, want %q", retrieved.SessionID, "test-session-id") 93 + } 94 + } 95 + 96 + func TestDeleteSession(t *testing.T) { 97 + mock := NewMockKeyring() 98 + store := NewKeyringAuthStoreWithKeyring(mock) 99 + ctx := context.Background() 100 + 101 + did, _ := syntax.ParseDID("did:plc:testuser") 102 + sess := oauth.ClientSessionData{ 103 + AccountDID: did, 104 + SessionID: "test-session-id", 105 + } 106 + 107 + // Save then delete 108 + store.SaveSession(ctx, sess) 109 + if err := store.DeleteSession(ctx, did, "test-session-id"); err != nil { 110 + t.Fatalf("DeleteSession() error = %v", err) 111 + } 112 + 113 + // Should not be found 114 + _, err := store.GetSession(ctx, did, "test-session-id") 115 + if err == nil { 116 + t.Error("GetSession() expected error after delete, got nil") 117 + } 118 + } 119 + 120 + func TestSaveAndGetAuthRequestInfo(t *testing.T) { 121 + mock := NewMockKeyring() 122 + store := NewKeyringAuthStoreWithKeyring(mock) 123 + ctx := context.Background() 124 + 125 + info := oauth.AuthRequestData{ 126 + State: "test-state-123", 127 + } 128 + 129 + // Save auth request 130 + if err := store.SaveAuthRequestInfo(ctx, info); err != nil { 131 + t.Fatalf("SaveAuthRequestInfo() error = %v", err) 132 + } 133 + 134 + // Get auth request 135 + retrieved, err := store.GetAuthRequestInfo(ctx, "test-state-123") 136 + if err != nil { 137 + t.Fatalf("GetAuthRequestInfo() error = %v", err) 138 + } 139 + 140 + if retrieved.State != "test-state-123" { 141 + t.Errorf("GetAuthRequestInfo() State = %q, want %q", retrieved.State, "test-state-123") 142 + } 143 + 144 + // Pending auth state should also be set 145 + state, err := store.GetPendingAuthState() 146 + if err != nil { 147 + t.Fatalf("GetPendingAuthState() error = %v", err) 148 + } 149 + if state != "test-state-123" { 150 + t.Errorf("GetPendingAuthState() = %q, want %q", state, "test-state-123") 151 + } 152 + } 153 + 154 + func TestDeleteAuthRequestInfo(t *testing.T) { 155 + mock := NewMockKeyring() 156 + store := NewKeyringAuthStoreWithKeyring(mock) 157 + ctx := context.Background() 158 + 159 + info := oauth.AuthRequestData{ 160 + State: "test-state-456", 161 + } 162 + 163 + store.SaveAuthRequestInfo(ctx, info) 164 + if err := store.DeleteAuthRequestInfo(ctx, "test-state-456"); err != nil { 165 + t.Fatalf("DeleteAuthRequestInfo() error = %v", err) 166 + } 167 + 168 + _, err := store.GetAuthRequestInfo(ctx, "test-state-456") 169 + if err == nil { 170 + t.Error("GetAuthRequestInfo() expected error after delete, got nil") 171 + } 172 + } 173 + 174 + func TestClearPendingAuthState(t *testing.T) { 175 + mock := NewMockKeyring() 176 + store := NewKeyringAuthStoreWithKeyring(mock) 177 + ctx := context.Background() 178 + 179 + info := oauth.AuthRequestData{ 180 + State: "test-state-789", 181 + } 182 + store.SaveAuthRequestInfo(ctx, info) 183 + 184 + if err := store.ClearPendingAuthState(); err != nil { 185 + t.Fatalf("ClearPendingAuthState() error = %v", err) 186 + } 187 + 188 + _, err := store.GetPendingAuthState() 189 + if err == nil { 190 + t.Error("GetPendingAuthState() expected error after clear, got nil") 191 + } 192 + } 193 + 194 + func TestSetAndGetCurrentSession(t *testing.T) { 195 + mock := NewMockKeyring() 196 + store := NewKeyringAuthStoreWithKeyring(mock) 197 + ctx := context.Background() 198 + 199 + did, _ := syntax.ParseDID("did:plc:currentuser") 200 + sess := oauth.ClientSessionData{ 201 + AccountDID: did, 202 + SessionID: "current-session-id", 203 + } 204 + 205 + // Save the actual session first 206 + if err := store.SaveSession(ctx, sess); err != nil { 207 + t.Fatalf("SaveSession() error = %v", err) 208 + } 209 + 210 + // Set as current session 211 + if err := store.SetCurrentSession(ctx, &sess); err != nil { 212 + t.Fatalf("SetCurrentSession() error = %v", err) 213 + } 214 + 215 + // Get current session 216 + retrieved, err := store.GetCurrentSession(ctx) 217 + if err != nil { 218 + t.Fatalf("GetCurrentSession() error = %v", err) 219 + } 220 + 221 + if retrieved.AccountDID.String() != did.String() { 222 + t.Errorf("GetCurrentSession() DID = %q, want %q", retrieved.AccountDID.String(), did.String()) 223 + } 224 + if retrieved.SessionID != "current-session-id" { 225 + t.Errorf("GetCurrentSession() SessionID = %q, want %q", retrieved.SessionID, "current-session-id") 226 + } 227 + } 228 + 229 + func TestClearCurrentSession(t *testing.T) { 230 + mock := NewMockKeyring() 231 + store := NewKeyringAuthStoreWithKeyring(mock) 232 + ctx := context.Background() 233 + 234 + did, _ := syntax.ParseDID("did:plc:currentuser") 235 + sess := oauth.ClientSessionData{ 236 + AccountDID: did, 237 + SessionID: "current-session-id", 238 + } 239 + 240 + store.SaveSession(ctx, sess) 241 + store.SetCurrentSession(ctx, &sess) 242 + 243 + if err := store.ClearCurrentSession(); err != nil { 244 + t.Fatalf("ClearCurrentSession() error = %v", err) 245 + } 246 + 247 + _, err := store.GetCurrentSession(ctx) 248 + if err == nil { 249 + t.Error("GetCurrentSession() expected error after clear, got nil") 250 + } 251 + } 252 + 253 + func TestSetAndGetLoginIdentifier(t *testing.T) { 254 + mock := NewMockKeyring() 255 + store := NewKeyringAuthStoreWithKeyring(mock) 256 + 257 + if err := store.SetLoginIdentifier("user.bsky.social"); err != nil { 258 + t.Fatalf("SetLoginIdentifier() error = %v", err) 259 + } 260 + 261 + id, err := store.GetLoginIdentifier() 262 + if err != nil { 263 + t.Fatalf("GetLoginIdentifier() error = %v", err) 264 + } 265 + 266 + if id != "user.bsky.social" { 267 + t.Errorf("GetLoginIdentifier() = %q, want %q", id, "user.bsky.social") 268 + } 269 + } 270 + 271 + func TestClearLoginIdentifier(t *testing.T) { 272 + mock := NewMockKeyring() 273 + store := NewKeyringAuthStoreWithKeyring(mock) 274 + 275 + store.SetLoginIdentifier("user.bsky.social") 276 + 277 + if err := store.ClearLoginIdentifier(); err != nil { 278 + t.Fatalf("ClearLoginIdentifier() error = %v", err) 279 + } 280 + 281 + _, err := store.GetLoginIdentifier() 282 + if err == nil { 283 + t.Error("GetLoginIdentifier() expected error after clear, got nil") 284 + } 285 + } 286 + 287 + func TestGetSessionNotFound(t *testing.T) { 288 + mock := NewMockKeyring() 289 + store := NewKeyringAuthStoreWithKeyring(mock) 290 + ctx := context.Background() 291 + 292 + did, _ := syntax.ParseDID("did:plc:nonexistent") 293 + _, err := store.GetSession(ctx, did, "no-such-session") 294 + if err == nil { 295 + t.Error("GetSession() expected error for non-existent session, got nil") 296 + } 297 + } 298 + 299 + func TestGetCurrentSessionNotFound(t *testing.T) { 300 + mock := NewMockKeyring() 301 + store := NewKeyringAuthStoreWithKeyring(mock) 302 + ctx := context.Background() 303 + 304 + _, err := store.GetCurrentSession(ctx) 305 + if err == nil { 306 + t.Error("GetCurrentSession() expected error when no current session, got nil") 307 + } 308 + } 309 + 310 + func TestGetLoginIdentifierNotFound(t *testing.T) { 311 + mock := NewMockKeyring() 312 + store := NewKeyringAuthStoreWithKeyring(mock) 313 + 314 + _, err := store.GetLoginIdentifier() 315 + if err == nil { 316 + t.Error("GetLoginIdentifier() expected error when not set, got nil") 317 + } 318 + }
+47
internal/ui/notification.go
··· 1 + package ui 2 + 3 + import ( 4 + "github.com/godbus/dbus/v5" 5 + ) 6 + 7 + const ( 8 + notifyService = "org.freedesktop.Notifications" 9 + notifyPath = "/org/freedesktop/Notifications" 10 + notifyInterface = "org.freedesktop.Notifications" 11 + ) 12 + 13 + // Notify sends a desktop notification via DBus 14 + func Notify(title, message, icon string) error { 15 + conn, err := dbus.ConnectSessionBus() 16 + if err != nil { 17 + return err 18 + } 19 + defer conn.Close() 20 + 21 + obj := conn.Object(notifyService, notifyPath) 22 + call := obj.Call(notifyInterface+".Notify", 0, 23 + "blup", // app_name 24 + uint32(0), // replaces_id 25 + icon, // app_icon 26 + title, // summary 27 + message, // body 28 + []string{}, // actions 29 + map[string]dbus.Variant{}, // hints 30 + int32(5000), // expire_timeout (ms) 31 + ) 32 + return call.Err 33 + } 34 + 35 + // NotifyLoginRequired shows a notification telling user to run blup login 36 + func NotifyLoginRequired() error { 37 + return Notify( 38 + "blup: Login Required", 39 + "Run 'blup login' in a terminal to authenticate.", 40 + "dialog-password", 41 + ) 42 + } 43 + 44 + // NotifyError shows an error notification 45 + func NotifyError(message string) error { 46 + return Notify("blup: Error", message, "dialog-error") 47 + }
+12
internal/ui/ui.go
··· 1 + package ui 2 + 3 + import ( 4 + "os" 5 + 6 + "golang.org/x/term" 7 + ) 8 + 9 + // IsInteractive returns true if stdin is connected to a terminal 10 + func IsInteractive() bool { 11 + return term.IsTerminal(int(os.Stdin.Fd())) 12 + }