diff --git a/.gitignore b/.gitignore index bdd0fc0..8dcd41c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -xurl +/xurl .xurl_test .DS_Store# Added by goreleaser init: dist/ diff --git a/Makefile b/Makefile index 2963684..21bb07f 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ .PHONY: build build: - go build -o xurl + go build -o xurl ./cmd/xurl .PHONY: install install: - go install + go install ./cmd/xurl .PHONY: clean clean: diff --git a/README.md b/README.md index e48007a..5a0f491 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,23 @@ Installs to `~/.local/bin`. If it's not in your PATH, the script will tell you w ### Go ```bash -go install github.com/xdevplatform/xurl@latest +go install github.com/xdevplatform/xurl/cmd/xurl@latest +``` + +### Use as a Go library + +Import by module path in other Go projects: + +```go +import "github.com/xdevplatform/xurl" +``` + +If your consuming project uses a local checkout of this repo, you can use a `replace` directive in your `go.mod` while still importing by the full module path: + +```go +require github.com/xdevplatform/xurl v0.0.0 + +replace github.com/xdevplatform/xurl => ../xurl ``` diff --git a/api/client.go b/api/client.go index 494fbd8..36803f8 100644 --- a/api/client.go +++ b/api/client.go @@ -1,19 +1,19 @@ package api import ( + "bufio" "bytes" "encoding/json" "errors" "fmt" "io" + "mime/multipart" "net/http" + "os" + "path/filepath" "strings" "time" - "bufio" - "mime/multipart" - "os" - "path/filepath" "github.com/xdevplatform/xurl/auth" "github.com/xdevplatform/xurl/config" xurlErrors "github.com/xdevplatform/xurl/errors" @@ -346,7 +346,8 @@ func (c *ApiClient) getAuthHeader(method, url string, authType string, username } // If no auth type is specified, try to use the first OAuth2 token - token := c.auth.TokenStore.GetFirstOAuth2Token() + // Use ForApp variants so the active app name (set via --app) is respected. + token := c.auth.TokenStore.GetFirstOAuth2TokenForApp(c.auth.AppName()) if token != nil { accessToken, err := c.auth.GetOAuth2Header(username) if err == nil { @@ -355,7 +356,7 @@ func (c *ApiClient) getAuthHeader(method, url string, authType string, username } // If no OAuth2 token is available, try to use the first OAuth1 token - token = c.auth.TokenStore.GetOAuth1Tokens() + token = c.auth.TokenStore.GetOAuth1TokensForApp(c.auth.AppName()) if token != nil { authHeader, err := c.auth.GetOAuth1Header(method, url, nil) if err == nil { diff --git a/api/client_test.go b/api/client_test.go index 869ed2b..5c57f0c 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/xdevplatform/xurl/auth" "github.com/xdevplatform/xurl/config" @@ -356,3 +357,81 @@ func TestStreamRequest(t *testing.T) { assert.True(t, xurlErrors.IsAPIError(err), "Expected API error") }) } + +// futureExpiry returns a unix timestamp 1 hour in the future. +func futureExpiry() uint64 { + return uint64(time.Now().Add(time.Hour).Unix()) +} + +// TC 5.3: ApiClient with multi-app Auth; app-b only has Bearer → BuildRequest uses app-b's Bearer +func TestTC5_3_ApiClientUsesAppBBearerNotDefaultOAuth2(t *testing.T) { + tempDir, err := os.MkdirTemp("", "xurl_api_multiapp_test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + tempFile := filepath.Join(tempDir, ".xurl") + ts := &store.TokenStore{ + Apps: make(map[string]*store.App), + DefaultApp: "app-a", + FilePath: tempFile, + } + + // app-a: has OAuth2 (default/active for auto-selection cascade) + ts.Apps["app-a"] = &store.App{ + ClientID: "id-a", + ClientSecret: "secret-a", + DefaultUser: "alice-a", + OAuth2Tokens: map[string]store.Token{ + "alice-a": { + Type: store.OAuth2TokenType, + OAuth2: &store.OAuth2Token{ + AccessToken: "oauth2-token-alice-a", + RefreshToken: "refresh-alice-a", + ExpirationTime: futureExpiry(), + }, + }, + }, + BearerToken: &store.Token{ + Type: store.BearerTokenType, + Bearer: "bearer-a", + }, + } + + // app-b: has ONLY Bearer token, no OAuth2 + ts.Apps["app-b"] = &store.App{ + ClientID: "id-b", + ClientSecret: "secret-b", + OAuth2Tokens: make(map[string]store.Token), + BearerToken: &store.Token{ + Type: store.BearerTokenType, + Bearer: "bearer-b-only", + }, + } + + // Build Auth starting with app-a credentials + a := auth.NewAuth(&config.Config{ + ClientID: "id-a", + ClientSecret: "secret-a", + APIBaseURL: "https://api.x.com", + AuthURL: "https://x.com/i/oauth2/authorize", + TokenURL: "https://api.x.com/2/oauth2/token", + RedirectURI: "http://localhost:8080/callback", + InfoURL: "https://api.x.com/2/users/me", + }).WithTokenStore(ts) + + // Switch to app-b + a.WithAppName("app-b") + + cfg := &config.Config{APIBaseURL: "https://api.x.com"} + client := NewApiClient(cfg, a) + + req, err := client.BuildRequest(RequestOptions{ + Method: "GET", + Endpoint: "/2/users/me", + }) + require.NoError(t, err) + + authHeader := req.Header.Get("Authorization") + assert.Equal(t, "Bearer bearer-b-only", authHeader, + "Authorization header must use app-b's Bearer token, not app-a's OAuth2") +} diff --git a/api/media.go b/api/media.go index f85506e..4f139bd 100644 --- a/api/media.go +++ b/api/media.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "time" + "github.com/xdevplatform/xurl/utils" ) diff --git a/auth/auth.go b/auth/auth.go index 9cffaa2..727b9cb 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -82,19 +82,20 @@ func (a *Auth) WithAppName(appName string) *Auth { a.appName = appName app := a.TokenStore.ResolveApp(appName) if app != nil { - if a.clientID == "" { - a.clientID = app.ClientID - } - if a.clientSecret == "" { - a.clientSecret = app.ClientSecret - } + a.clientID = app.ClientID + a.clientSecret = app.ClientSecret } return a } +// AppName returns the current app name override (may be empty). +func (a *Auth) AppName() string { + return a.appName +} + // GetOAuth1Header gets the OAuth1 header for a request func (a *Auth) GetOAuth1Header(method, urlStr string, additionalParams map[string]string) (string, error) { - token := a.TokenStore.GetOAuth1Tokens() + token := a.TokenStore.GetOAuth1TokensForApp(a.appName) if token == nil || token.OAuth1 == nil { return "", xurlErrors.NewAuthError("TokenNotFound", errors.New("OAuth1 token not found")) } @@ -146,9 +147,9 @@ func (a *Auth) GetOAuth2Header(username string) (string, error) { var token *store.Token if username != "" { - token = a.TokenStore.GetOAuth2Token(username) + token = a.TokenStore.GetOAuth2TokenForApp(a.appName, username) } else { - token = a.TokenStore.GetFirstOAuth2Token() + token = a.TokenStore.GetFirstOAuth2TokenForApp(a.appName) } if token == nil { @@ -253,7 +254,7 @@ func (a *Auth) OAuth2Flow(username string) (string, error) { expirationTime := uint64(time.Now().Add(time.Duration(token.Expiry.Unix()-time.Now().Unix()) * time.Second).Unix()) - err = a.TokenStore.SaveOAuth2Token(usernameStr, token.AccessToken, token.RefreshToken, expirationTime) + err = a.TokenStore.SaveOAuth2TokenForApp(a.appName, usernameStr, token.AccessToken, token.RefreshToken, expirationTime) if err != nil { return "", xurlErrors.NewAuthError("TokenStorageError", err) } @@ -266,9 +267,9 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) { var token *store.Token if username != "" { - token = a.TokenStore.GetOAuth2Token(username) + token = a.TokenStore.GetOAuth2TokenForApp(a.appName, username) } else { - token = a.TokenStore.GetFirstOAuth2Token() + token = a.TokenStore.GetFirstOAuth2TokenForApp(a.appName) } if token == nil || token.OAuth2 == nil { @@ -310,7 +311,7 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) { expirationTime := uint64(time.Now().Add(time.Duration(newToken.Expiry.Unix()-time.Now().Unix()) * time.Second).Unix()) - err = a.TokenStore.SaveOAuth2Token(usernameStr, newToken.AccessToken, newToken.RefreshToken, expirationTime) + err = a.TokenStore.SaveOAuth2TokenForApp(a.appName, usernameStr, newToken.AccessToken, newToken.RefreshToken, expirationTime) if err != nil { return "", xurlErrors.NewAuthError("RefreshTokenError", err) } @@ -320,7 +321,7 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) { // GetBearerTokenHeader gets the bearer token from the token store func (a *Auth) GetBearerTokenHeader() (string, error) { - token := a.TokenStore.GetBearerToken() + token := a.TokenStore.GetBearerTokenForApp(a.appName) if token == nil { return "", xurlErrors.NewAuthError("TokenNotFound", errors.New("bearer token not found")) } diff --git a/auth/auth_test.go b/auth/auth_test.go index 9faebbb..100f52c 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -246,6 +247,219 @@ func TestOAuth1HeaderWithTokenStore(t *testing.T) { assert.Contains(t, header, "oauth_consumer_key") } +// futureExpiry returns a unix timestamp 1 hour in the future. +func futureExpiry() uint64 { + return uint64(time.Now().Add(time.Hour).Unix()) +} + +// setupMultiAppAuth creates a token store with two apps for multi-app tests. +func setupMultiAppAuth(t *testing.T) (*Auth, *store.TokenStore, string) { + tempDir, err := os.MkdirTemp("", "xurl_multiapp_test") + require.NoError(t, err) + + tempFile := filepath.Join(tempDir, ".xurl") + ts := &store.TokenStore{ + Apps: make(map[string]*store.App), + DefaultApp: "app-a", + FilePath: tempFile, + } + + ts.Apps["app-a"] = &store.App{ + ClientID: "id-a", + ClientSecret: "secret-a", + DefaultUser: "alice-a", + OAuth2Tokens: map[string]store.Token{ + "alice-a": { + Type: store.OAuth2TokenType, + OAuth2: &store.OAuth2Token{ + AccessToken: "oauth2-token-alice-a", + RefreshToken: "refresh-alice-a", + ExpirationTime: futureExpiry(), + }, + }, + }, + OAuth1Token: &store.Token{ + Type: store.OAuth1TokenType, + OAuth1: &store.OAuth1Token{ + AccessToken: "at-a", + TokenSecret: "ts-a", + ConsumerKey: "ck-a", + ConsumerSecret: "cs-a", + }, + }, + BearerToken: &store.Token{ + Type: store.BearerTokenType, + Bearer: "bearer-a", + }, + } + + ts.Apps["app-b"] = &store.App{ + ClientID: "id-b", + ClientSecret: "secret-b", + DefaultUser: "alice-b", + OAuth2Tokens: map[string]store.Token{ + "alice-b": { + Type: store.OAuth2TokenType, + OAuth2: &store.OAuth2Token{ + AccessToken: "oauth2-token-alice-b", + RefreshToken: "refresh-alice-b", + ExpirationTime: futureExpiry(), + }, + }, + }, + OAuth1Token: &store.Token{ + Type: store.OAuth1TokenType, + OAuth1: &store.OAuth1Token{ + AccessToken: "at-b", + TokenSecret: "ts-b", + ConsumerKey: "ck-b", + ConsumerSecret: "cs-b", + }, + }, + BearerToken: &store.Token{ + Type: store.BearerTokenType, + Bearer: "bearer-b", + }, + } + + a := NewAuth(&config.Config{ + ClientID: "id-a", + ClientSecret: "secret-a", + APIBaseURL: "https://api.x.com", + AuthURL: "https://x.com/i/oauth2/authorize", + TokenURL: "https://api.x.com/2/oauth2/token", + RedirectURI: "http://localhost:8080/callback", + InfoURL: "https://api.x.com/2/users/me", + }).WithTokenStore(ts) + + return a, ts, tempDir +} + +// TC 5.1: WithAppName overwrites non-empty clientID/clientSecret +func TestTC5_1_WithAppNameOverwritesNonEmptyCredentials(t *testing.T) { + a, _, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + // Auth starts with app-a's non-empty credentials + require.Equal(t, "id-a", a.clientID) + require.Equal(t, "secret-a", a.clientSecret) + + // Switch to app-b — must overwrite even though clientID/clientSecret are non-empty + a.WithAppName("app-b") + assert.Equal(t, "id-b", a.clientID, "WithAppName must overwrite non-empty clientID") + assert.Equal(t, "secret-b", a.clientSecret, "WithAppName must overwrite non-empty clientSecret") +} + +// TC 5.2: After WithAppName("app-b"), verify a.clientID and a.clientSecret match app-b +func TestTC5_2_ClientCredentialsMatchApp(t *testing.T) { + a, ts, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + a.WithAppName("app-b") + assert.Equal(t, ts.Apps["app-b"].ClientID, a.clientID) + assert.Equal(t, ts.Apps["app-b"].ClientSecret, a.clientSecret) + assert.Equal(t, "app-b", a.appName) +} + +// TC 5.4: app-b has "alice-b" (default_user) and "bob-b" → GetOAuth2Header("") returns alice-b's token +func TestTC5_4_DefaultUserOAuth2(t *testing.T) { + a, ts, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + // Add "bob-b" to app-b as well + ts.Apps["app-b"].OAuth2Tokens["bob-b"] = store.Token{ + Type: store.OAuth2TokenType, + OAuth2: &store.OAuth2Token{ + AccessToken: "oauth2-token-bob-b", + RefreshToken: "refresh-bob-b", + ExpirationTime: futureExpiry(), + }, + } + + a.WithAppName("app-b") + // DefaultUser is "alice-b", so GetOAuth2Header("") should return alice-b's token + header, err := a.GetOAuth2Header("") + require.NoError(t, err) + assert.Equal(t, "Bearer oauth2-token-alice-b", header) +} + +// TC 5.5: After WithAppName("app-b"), SaveOAuth2TokenForApp stores in app-b +func TestTC5_5_SaveOAuth2TokenGoesToActiveApp(t *testing.T) { + a, ts, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + a.WithAppName("app-b") + + // Save a new token through the store using a.appName + err := ts.SaveOAuth2TokenForApp(a.appName, "newuser-b", "new-access-b", "new-refresh-b", futureExpiry()) + require.NoError(t, err) + + // Verify the token is in app-b + tok := ts.GetOAuth2TokenForApp("app-b", "newuser-b") + require.NotNil(t, tok) + assert.Equal(t, "new-access-b", tok.OAuth2.AccessToken) + + // Verify app-a is untouched + tokA := ts.GetOAuth2TokenForApp("app-a", "newuser-b") + assert.Nil(t, tokA, "Token should not exist in app-a") +} + +// TC 5.6: WithAppName("app-b") → ClearAllForApp → only app-b cleared, default untouched +func TestTC5_6_ClearOnlyActiveApp(t *testing.T) { + a, ts, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + a.WithAppName("app-b") + + // Clear all tokens for app-b + err := ts.ClearAllForApp(a.appName) + require.NoError(t, err) + + // app-b should have no tokens + assert.Nil(t, ts.GetBearerTokenForApp("app-b"), "app-b bearer should be cleared") + assert.Nil(t, ts.GetOAuth1TokensForApp("app-b"), "app-b OAuth1 should be cleared") + assert.Empty(t, ts.GetOAuth2UsernamesForApp("app-b"), "app-b OAuth2 tokens should be cleared") + + // app-a should be untouched + assert.NotNil(t, ts.GetBearerTokenForApp("app-a"), "app-a bearer must remain") + assert.NotNil(t, ts.GetOAuth1TokensForApp("app-a"), "app-a OAuth1 must remain") + assert.NotEmpty(t, ts.GetOAuth2UsernamesForApp("app-a"), "app-a OAuth2 tokens must remain") +} + +// TestAppNameGetter verifies the AppName() getter returns the current override. +func TestAppNameGetter(t *testing.T) { + a, _, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + // Initial app name comes from config (empty in setupMultiAppAuth) + assert.Empty(t, a.AppName()) + + a.WithAppName("app-b") + assert.Equal(t, "app-b", a.AppName()) +} + +// TestBearerTokenSwitchBetweenApps verifies bearer tokens switch with app context. +func TestBearerTokenSwitchBetweenApps(t *testing.T) { + a, _, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + rounds := []struct { + app string + bearer string + }{ + {"app-a", "bearer-a"}, + {"app-b", "bearer-b"}, + {"app-a", "bearer-a"}, + } + + for i, r := range rounds { + a.WithAppName(r.app) + header, err := a.GetBearerTokenHeader() + require.NoError(t, err, "round %d: unexpected error", i) + assert.Equal(t, "Bearer "+r.bearer, header, "round %d: wrong bearer for %s", i, r.app) + } +} + func TestGetOAuth2HeaderNoToken(t *testing.T) { tokenStore, tempDir := createTempTokenStore(t) defer os.RemoveAll(tempDir) diff --git a/main.go b/cmd/xurl/main.go similarity index 100% rename from main.go rename to cmd/xurl/main.go diff --git a/xurl.go b/xurl.go new file mode 100644 index 0000000..b051f93 --- /dev/null +++ b/xurl.go @@ -0,0 +1,27 @@ +package xurl + +import ( + "github.com/spf13/cobra" + + "github.com/xdevplatform/xurl/auth" + "github.com/xdevplatform/xurl/cli" + "github.com/xdevplatform/xurl/config" +) + +// NewRootCommand creates the root Cobra command with default configuration. +func NewRootCommand() *cobra.Command { + cfg := config.NewConfig() + a := auth.NewAuth(cfg) + + return cli.CreateRootCommand(cfg, a) +} + +// CreateRootCommand creates the root Cobra command using caller-provided dependencies. +func CreateRootCommand(cfg *config.Config, a *auth.Auth) *cobra.Command { + return cli.CreateRootCommand(cfg, a) +} + +// Execute runs the root command. +func Execute() error { + return NewRootCommand().Execute() +}