diff --git a/api/client.go b/api/client.go index 494fbd8..2e2eec1 100644 --- a/api/client.go +++ b/api/client.go @@ -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/auth/auth.go b/auth/auth.go index 9cffaa2..acfdffc 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 // unconditional override + a.clientSecret = app.ClientSecret // unconditional override } return a } +// AppName returns the current explicit app name override. +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")) } @@ -141,14 +142,14 @@ func (a *Auth) GetOAuth1Header(method, urlStr string, additionalParams map[strin return "OAuth " + strings.Join(oauthParams, ", "), nil } -// GetOAuth2Token gets or refreshes an OAuth2 token +// GetOAuth2Header gets or refreshes an OAuth2 token 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..604aae1 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" @@ -12,15 +13,14 @@ import ( "github.com/xdevplatform/xurl/store" ) -// Helper function to create a temporary token store for testing +// ─── Helpers ──────────────────────────────────────────────────────── + +// createTempTokenStore creates a temporary token store for basic tests (single default app). func createTempTokenStore(t *testing.T) (*store.TokenStore, string) { - // Create a temporary directory for testing + t.Helper() tempDir, err := os.MkdirTemp("", "xurl_test") - if err != nil { - t.Fatalf("Failed to create temp directory: %v", err) - } + require.NoError(t, err) - // Create a token store with a file in the temp directory tempFile := filepath.Join(tempDir, ".xurl") ts := &store.TokenStore{ Apps: make(map[string]*store.App), @@ -30,10 +30,108 @@ func createTempTokenStore(t *testing.T) (*store.TokenStore, string) { ts.Apps["default"] = &store.App{ OAuth2Tokens: make(map[string]store.Token), } - return ts, tempDir } +// futureExpiry returns a Unix timestamp 1 hour in the future. +func futureExpiry() uint64 { + return uint64(time.Now().Add(time.Hour).Unix()) +} + +// setupMultiAppAuth creates a two-app token store and an Auth pre-configured with app-a's credentials. +// +// app-a (default): OAuth2("alice-a"), OAuth1, Bearer, clientID:"id-a", clientSecret:"secret-a" +// app-b: OAuth2("alice-b"), OAuth1, Bearer, clientID:"id-b", clientSecret:"secret-b" +func setupMultiAppAuth(t *testing.T) (*Auth, *store.TokenStore, string) { + t.Helper() + + 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, + } + + // app-a + 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", + }, + } + + // app-b + 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", + }, + } + + // Auth starts with app-a credentials (simulating NewAuth with app-a as default) + a := &Auth{ + TokenStore: ts, + clientID: "id-a", + clientSecret: "secret-a", + appName: "app-a", + 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", + } + + return a, ts, tempDir +} + +// ─── Existing tests (preserved) ───────────────────────────────────── + func TestNewAuth(t *testing.T) { cfg := &config.Config{ ClientID: "test-client-id", @@ -129,7 +227,7 @@ func TestEncode(t *testing.T) { for _, tc := range testCases { t.Run(tc.input, func(t *testing.T) { result := encode(tc.input) - assert.Equal(t, tc.expected, result, "encode(%q) should return %q", tc.input, result) + assert.Equal(t, tc.expected, result, "encode(%q) should return %q", tc.input, tc.expected) }) } } @@ -146,8 +244,6 @@ func TestGetOAuth2Scopes(t *testing.T) { scopes := getOAuth2Scopes() assert.NotEmpty(t, scopes, "Expected non-empty scopes") - - // Check for some common scopes assert.Contains(t, scopes, "tweet.read", "Expected 'tweet.read' scope") assert.Contains(t, scopes, "users.read", "Expected 'users.read' scope") } @@ -156,10 +252,9 @@ func TestCredentialResolutionPriority(t *testing.T) { tokenStore, tempDir := createTempTokenStore(t) defer os.RemoveAll(tempDir) - // Store has credentials in the default app tokenStore.Apps["default"].ClientID = "store-id" tokenStore.Apps["default"].ClientSecret = "store-secret" - tokenStore.SaveBearerToken("x") // force save + tokenStore.SaveBearerToken("x") t.Run("Env vars take priority over store", func(t *testing.T) { cfg := &config.Config{ @@ -172,8 +267,6 @@ func TestCredentialResolutionPriority(t *testing.T) { }) t.Run("Store used when env vars empty", func(t *testing.T) { - // Simulate what NewAuth does when env vars are empty: - // it should fall back to the store's app credentials. a := &Auth{ TokenStore: tokenStore, } @@ -194,16 +287,13 @@ func TestWithAppName(t *testing.T) { tokenStore, tsDir := createTempTokenStore(t) defer os.RemoveAll(tsDir) - // Add a second app with different credentials tokenStore.AddApp("other", "other-id", "other-secret") cfg := &config.Config{} a := NewAuth(cfg).WithTokenStore(tokenStore) - // Initially no app override — clientID/secret are empty (no env vars, default app has none) assert.Empty(t, a.clientID) - // Set app name — should pick up other app's credentials a.WithAppName("other") assert.Equal(t, "other-id", a.clientID) assert.Equal(t, "other-secret", a.clientSecret) @@ -221,9 +311,7 @@ func TestWithAppNameNonexistent(t *testing.T) { cfg := &config.Config{} a := NewAuth(cfg).WithTokenStore(tokenStore) - // Setting a nonexistent app name should not panic a.WithAppName("doesnt-exist") - // Should fall through to default app (which has empty creds) assert.Empty(t, a.clientID) } @@ -234,11 +322,9 @@ func TestOAuth1HeaderWithTokenStore(t *testing.T) { cfg := &config.Config{} a := NewAuth(cfg).WithTokenStore(tokenStore) - // No OAuth1 token — should fail _, err := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil) assert.Error(t, err) - // Save OAuth1 token and try again tokenStore.SaveOAuth1Tokens("at", "ts", "ck", "cs") header, err := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil) require.NoError(t, err) @@ -260,7 +346,310 @@ func TestGetOAuth2HeaderNoToken(t *testing.T) { } _ = NewAuth(cfg).WithTokenStore(tokenStore) - // Verify that looking up a nonexistent user returns nil token := tokenStore.GetOAuth2Token("nobody") assert.Nil(t, token) } + +// ─── 1. Happy Path ─────────────────────────────────────────────────── + +// TC 1.1: WithAppName("app-b") → GetOAuth1Header() returns app-b's consumer key +func TestTC1_1_WithAppBGetOAuth1Header(t *testing.T) { + a, _, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + a.WithAppName("app-b") + header, err := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil) + require.NoError(t, err) + assert.Contains(t, header, "OAuth ") + // The OAuth1 header format wraps values in literal double-quotes + assert.Contains(t, header, `oauth_consumer_key="ck-b"`) +} + +// TC 1.2: WithAppName("app-b") → GetOAuth2Header("") returns app-b's access token +func TestTC1_2_WithAppBGetOAuth2Header(t *testing.T) { + a, _, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + a.WithAppName("app-b") + header, err := a.GetOAuth2Header("") + require.NoError(t, err) + assert.Equal(t, "Bearer oauth2-token-alice-b", header) +} + +// TC 1.3: WithAppName("app-b") → GetBearerTokenHeader() returns app-b's bearer +func TestTC1_3_WithAppBGetBearerTokenHeader(t *testing.T) { + a, _, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + a.WithAppName("app-b") + header, err := a.GetBearerTokenHeader() + require.NoError(t, err) + assert.Equal(t, "Bearer bearer-b", header) +} + +// ─── 2. Edge Cases ─────────────────────────────────────────────────── + +// TC 2.1: WithAppName("") → returns default app's tokens +func TestTC2_1_WithAppNameEmptyReturnsDefault(t *testing.T) { + a, _, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + // Start with app-b, then clear to empty to reset to default + a.WithAppName("") + header, err := a.GetBearerTokenHeader() + require.NoError(t, err) + // Empty string resolves to the store's default app (app-a) + assert.Equal(t, "Bearer bearer-a", header) +} + +// TC 2.2: WithAppName("app-a") (same as default) → works correctly +func TestTC2_2_WithAppNameSameAsDefault(t *testing.T) { + a, _, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + a.WithAppName("app-a") + header, err := a.GetBearerTokenHeader() + require.NoError(t, err) + assert.Equal(t, "Bearer bearer-a", header) + + oauth1, err := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil) + require.NoError(t, err) + assert.Contains(t, oauth1, `oauth_consumer_key="ck-a"`) +} + +// TC 2.3: Sequential switching: app-a → get token → app-b → get token → verify each correct +func TestTC2_3_SequentialSwitching(t *testing.T) { + a, _, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + // Check app-a (already set) + headerA, err := a.GetBearerTokenHeader() + require.NoError(t, err) + assert.Equal(t, "Bearer bearer-a", headerA) + + // Switch to app-b + a.WithAppName("app-b") + headerB, err := a.GetBearerTokenHeader() + require.NoError(t, err) + assert.Equal(t, "Bearer bearer-b", headerB) + + // Switch back to app-a + a.WithAppName("app-a") + headerA2, err := a.GetBearerTokenHeader() + require.NoError(t, err) + assert.Equal(t, "Bearer bearer-a", headerA2) +} + +// ─── 3. Error Conditions ──────────────────────────────────────────── + +// TC 3.1: WithAppName("ghost-app") → falls back to default (current ResolveApp behavior) +func TestTC3_1_NonexistentAppFallsBackToDefault(t *testing.T) { + a, _, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + a.WithAppName("ghost-app") + // ResolveApp("ghost-app") returns default app (app-a) since "ghost-app" doesn't exist + header, err := a.GetBearerTokenHeader() + require.NoError(t, err) + assert.Equal(t, "Bearer bearer-a", header) +} + +// TC 3.2: app-b exists but has NO OAuth1 → GetOAuth1Header() returns error, NOT default's OAuth1 +func TestTC3_2_AppBNoOAuth1ReturnsError(t *testing.T) { + a, ts, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + // Remove OAuth1 from app-b + ts.Apps["app-b"].OAuth1Token = nil + + a.WithAppName("app-b") + _, err := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil) + // Must return an error — should NOT silently fall back to app-a's OAuth1 + require.Error(t, err, "Expected error when app-b has no OAuth1 token") + + // Confirm app-a still has its OAuth1 token (wasn't cleared) + a.WithAppName("app-a") + headerA, err2 := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil) + require.NoError(t, err2) + assert.Contains(t, headerA, `oauth_consumer_key="ck-a"`) +} + +// TC 3.3: app-b has OAuth2 for "alice-b" not "bob" → GetOAuth2TokenForApp returns nil for "bob", +// and does NOT return app-a's "bob" token either. Verifies the store lookup is app-scoped. +func TestTC3_3_AppBNoOAuth2ForBob(t *testing.T) { + a, ts, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + // Give app-a a "bob" token to confirm it is NOT returned for app-b + ts.Apps["app-a"].OAuth2Tokens["bob"] = store.Token{ + Type: store.OAuth2TokenType, + OAuth2: &store.OAuth2Token{ + AccessToken: "oauth2-token-bob-a", + RefreshToken: "refresh-bob-a", + ExpirationTime: futureExpiry(), + }, + } + + a.WithAppName("app-b") + + // Store-level check: GetOAuth2TokenForApp("app-b", "bob") must return nil + tok := ts.GetOAuth2TokenForApp(a.appName, "bob") + assert.Nil(t, tok, "app-b must not have a token for 'bob'") + + // app-a has one; confirm it is NOT leaked via appName scoping + tokA := ts.GetOAuth2TokenForApp("app-a", "bob") + require.NotNil(t, tokA) + assert.Equal(t, "oauth2-token-bob-a", tokA.OAuth2.AccessToken) + + // The Auth.appName is correctly pointing at app-b, so RefreshOAuth2Token would error. + // We test that directly (without triggering the full OAuth2Flow/browser redirect): + _, err := a.RefreshOAuth2Token("bob") + require.Error(t, err, "RefreshOAuth2Token must error when app-b has no 'bob' token") +} + +// ─── 4. Boundary ──────────────────────────────────────────────────── + +// TC 4.1: Single app store → WithAppName("default") works normally +func TestTC4_1_SingleAppStore(t *testing.T) { + tokenStore, tempDir := createTempTokenStore(t) + defer os.RemoveAll(tempDir) + + // Set up the single default app with a bearer token + tokenStore.Apps["default"].ClientID = "single-id" + tokenStore.Apps["default"].ClientSecret = "single-secret" + err := tokenStore.SaveBearerToken("single-bearer") + require.NoError(t, err) + + a := &Auth{ + TokenStore: tokenStore, + clientID: "single-id", + clientSecret: "single-secret", + appName: "default", + } + + a.WithAppName("default") + assert.Equal(t, "single-id", a.clientID) + assert.Equal(t, "single-secret", a.clientSecret) + + header, err := a.GetBearerTokenHeader() + require.NoError(t, err) + assert.Equal(t, "Bearer single-bearer", header) +} + +// TC 4.2: Rapid back-and-forth switching (5+ times) → correct tokens each time +func TestTC4_2_RapidSwitching(t *testing.T) { + a, _, tempDir := setupMultiAppAuth(t) + defer os.RemoveAll(tempDir) + + rounds := []struct { + app string + bearer string + }{ + {"app-b", "bearer-b"}, + {"app-a", "bearer-a"}, + {"app-b", "bearer-b"}, + {"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) + } +} + +// ─── 5. Domain-Specific ───────────────────────────────────────────── + +// TC 5.1: WithAppName overwrites non-empty clientID/clientSecret (Bug #1) +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") +}