|
1 | 1 | package config |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "context" |
4 | 5 | "errors" |
5 | 6 | "net/http" |
6 | 7 | "net/url" |
| 8 | + "sync/atomic" |
7 | 9 | "testing" |
8 | 10 |
|
| 11 | + "github.com/databricks/databricks-sdk-go/config/credentials" |
9 | 12 | "github.com/databricks/databricks-sdk-go/credentials/u2m" |
10 | 13 | "github.com/databricks/databricks-sdk-go/httpclient/fixtures" |
11 | 14 | "golang.org/x/oauth2" |
@@ -96,6 +99,83 @@ func TestM2mNotSupported(t *testing.T) { |
96 | 99 | } |
97 | 100 | } |
98 | 101 |
|
| 102 | +// TestM2mCredentials_DirectTokenSource verifies that M2mCredentials.Configure |
| 103 | +// plumbs a direct token source (ccfg.Token) through cachedTokenSource rather |
| 104 | +// than wrapping clientcredentials.Config.TokenSource, which returns an |
| 105 | +// oauth2.ReuseTokenSource that adds a second cache layer. With double-caching, |
| 106 | +// the proactive async refresh in cachedTokenSource is silently suppressed until |
| 107 | +// ~10 s before expiry, causing bursts of 401 errors at token rotation boundaries. |
| 108 | +// See https://github.com/databricks/databricks-sdk-go/issues/1549. |
| 109 | +func TestM2mCredentials_DirectTokenSource(t *testing.T) { |
| 110 | + var tokenCalls int32 |
| 111 | + transport := &postCountingTransport{ |
| 112 | + calls: &tokenCalls, |
| 113 | + inner: fixtures.MappingTransport{ |
| 114 | + "GET /oidc/.well-known/oauth-authorization-server": { |
| 115 | + Response: u2m.OAuthAuthorizationServer{ |
| 116 | + TokenEndpoint: "https://localhost/token", |
| 117 | + }, |
| 118 | + }, |
| 119 | + "POST /token": { |
| 120 | + Response: oauth2.Token{ |
| 121 | + TokenType: "Bearer", |
| 122 | + AccessToken: "test-token", |
| 123 | + }, |
| 124 | + }, |
| 125 | + }, |
| 126 | + } |
| 127 | + |
| 128 | + cfg := &Config{ |
| 129 | + Host: "a", |
| 130 | + ClientID: "b", |
| 131 | + ClientSecret: "c", |
| 132 | + AuthType: "oauth-m2m", |
| 133 | + ConfigFile: "/dev/null", |
| 134 | + HTTPTransport: transport, |
| 135 | + } |
| 136 | + |
| 137 | + err := cfg.EnsureResolved() |
| 138 | + if err != nil { |
| 139 | + t.Fatalf("EnsureResolved(): %v", err) |
| 140 | + } |
| 141 | + |
| 142 | + ctx := cfg.refreshClient.InContextForOAuth2(cfg.refreshCtx) |
| 143 | + provider, err := M2mCredentials{}.Configure(ctx, cfg) |
| 144 | + if err != nil { |
| 145 | + t.Fatalf("Configure(): %v", err) |
| 146 | + } |
| 147 | + |
| 148 | + oauthProvider := provider.(credentials.OAuthCredentialsProvider) |
| 149 | + |
| 150 | + // Token() goes through cachedTokenSource, which fetches once and caches. |
| 151 | + // Verify the endpoint is reached (not short-circuited by an inner cache). |
| 152 | + tok, err := oauthProvider.Token(context.Background()) |
| 153 | + if err != nil { |
| 154 | + t.Fatalf("Token(): %v", err) |
| 155 | + } |
| 156 | + if tok.AccessToken == "" { |
| 157 | + t.Fatalf("Token(): empty access token") |
| 158 | + } |
| 159 | + |
| 160 | + if got := int(atomic.LoadInt32(&tokenCalls)); got != 1 { |
| 161 | + t.Errorf("token endpoint calls = %d, want 1", got) |
| 162 | + } |
| 163 | +} |
| 164 | + |
| 165 | +type postCountingTransport struct { |
| 166 | + calls *int32 |
| 167 | + inner http.RoundTripper |
| 168 | +} |
| 169 | + |
| 170 | +func (t *postCountingTransport) RoundTrip(req *http.Request) (*http.Response, error) { |
| 171 | + if req.Method == "POST" { |
| 172 | + atomic.AddInt32(t.calls, 1) |
| 173 | + } |
| 174 | + return t.inner.RoundTrip(req) |
| 175 | +} |
| 176 | + |
| 177 | +func (t *postCountingTransport) SkipRetryOnIO() bool { return true } |
| 178 | + |
99 | 179 | func TestM2M_Scopes(t *testing.T) { |
100 | 180 | tests := []struct { |
101 | 181 | name string |
|
0 commit comments