blob: 2748d59e11893b2564bfea6b91b82075b233021c [file]
/*
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package token
import (
"bytes"
"io"
"net/http"
"testing"
"time"
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
"github.com/apache/incubator-devlake/impls/logruslog"
"github.com/apache/incubator-devlake/plugins/github/models"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// testRSAKey is a throwaway 2048-bit RSA private key used only in tests.
// It is NOT a real credential.
const testRSAKey = `-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA1xJuX407giVTO/FY2pbp6bdB/XxaiPqAuvWIcEqabzq+d3ft
O7fGtbXSQrCdtxEQt5dHFKdJofHcGlKPDnq1BNjWM3/xLWsQWQPSwUZ9H1qy/nDI
GX+ciXmP8hbzoe5B1OXidAdrJUGWH3ox8Yp8OVd/yK9p34teCbzPnqVEc9tkgUT1
94gHKLmvP28VefybFyGbYB3ujVIuA8Z26c4gQsyFzR2v1fVeDIu1e1afyH5WTFgr
EWaztOo6pI5stzH00U7fNMzULBuYQ+ufQ4iQ7Ewt7fK5yyQNkx3pX0o1OQ/aYxQr
hyBrHakxHBe78eq8rSR2KwSr5nDuYzIAOUhtnwIDAQABAoH/EknhZ6EkvRPR7G5e
bq4/NduuSriDcITwbWuuuFPufzgdIZVzlu8xHfPOT9152qE2aD7XahGUk85fwbXh
EeP64x/cA3FnmrAxPYfuUFnB0+i7KHGpzW2Wa/LqXlz3vS/UQePoDwl+xDYeUt2u
xALHoSxZdlTWv6HLhLmw7ge9QJLc/xQO9dC678c4Y4JTCRrEvhE+eZiDEXb6HL2D
uNMEwFqMLTxOurYKXE+iyzKg0e4D6oDkw6BC+vOUBnuH+9iKV6wnal24E1WnyhCa
vy7iHBoc8npeKYAHjU5wKDQiWPMT9DjYBRucHvSyEYTeS5eyQHed42PGv+Ss2IL4
w0JZAoGBAPjxiEJwck29NyfElw5+/P0IKIE8BkuIOSC8JoGfo+JpKYWFIXaLRbK8
qvOiLDotHgC5IxFZyN8pejVe7Zvif5PepR9qMcV3e6Uz2nmJqtp5gFlzjNUSzbeM
J6gqkejtwn7wJY3dwZhfbTTDDY7cZ9f3Cydarx+iu07unGCRT9DDAoGBAN0rG/F9
tsOSwrgsnPwNeVPfJn2Zw6mvb1xuOoJl11tDnpS6tH50FBJ/TkERZak9+VZ761pq
CvuMjLtePgC31rsAcUEEt1OwYCxne8gNxuzl2mWKD98ActbFf96VM4Q68Jvntxl1
eOMMnSA+/yjvhpxdVabm3180mRu9eBv6SaH1AoGBAMpwL6xHoMwS6L1QIr7JCZYC
gl3FoCDgIAS8vFuApFbDyd4oSvQJgZ49yo7g/DI66kEQTLIZXz4KjrTEA1lWsQRg
c8q+Isc/yK6pIirfhq6vS25yhr3m0p9GPCGGrKzMW/O5+fAJuxrbzwSu8WGRXmjD
HrDcD7kcLlGbvFLTGCLdAoGBALNID7W5Z16v6AItv++d6Hzxhh0IeRBi8s2lWO59
KY6EiNcdZdSfuemoosGiHZuMbkMJ3qWDEnYI38e+xFoGrB0YZbYD4awIbF1yYWew
q1E7ncbznJvznCO3I0lF/uWwdXyb39PWYvECN5h9GI+RYrf7/MN3oRhm5boT43oi
cG/FAoGBALD1zSG9qrysLg8fw4dc4cs6dAHZhAszh47zw2WiMmUVWQZCbB+uhs44
qGCAaer5KdnTqy9NdwW6rlcr4Y8jUFnMlMFA5HdmHkIfgTi+zi4Qf1mb9yzpJqnU
jKflsh1Lyyqv2KsoIMz4vjew+lCVn80FZaEEQ1q9tAkyu5m53w65
-----END RSA PRIVATE KEY-----`
func TestRoundTripper401Refresh(t *testing.T) {
mockRT := new(MockRoundTripper)
client := &http.Client{Transport: mockRT}
expiry := time.Now().Add(10 * time.Minute) // Not expired
conn := &models.GithubConnection{
GithubConn: models.GithubConn{
RefreshToken: "refresh_token",
GithubAccessToken: models.GithubAccessToken{
AccessToken: api.AccessToken{
Token: "old_token",
},
},
TokenExpiresAt: &expiry,
GithubAppKey: models.GithubAppKey{
AppKey: api.AppKey{
AppId: "123",
SecretKey: "secret",
},
},
},
}
logger, _ := logruslog.NewDefaultLogger(logrus.New())
tp := NewTokenProvider(conn, nil, client, logger, "")
rt := NewRefreshRoundTripper(mockRT, tp)
// Request
req, _ := http.NewRequest("GET", "https://api.github.com/user", nil)
// 1. First call returns 401
resp401 := &http.Response{
StatusCode: 401,
Body: io.NopCloser(bytes.NewBufferString("Unauthorized")),
}
mockRT.On("RoundTrip", mock.MatchedBy(func(r *http.Request) bool {
return r.Header.Get("Authorization") == "Bearer old_token" && r.URL.String() == "https://api.github.com/user"
})).Return(resp401, nil).Once()
// 2. Refresh call (triggered by 401)
respRefresh := &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"access_token":"new_token","refresh_token":"new_refresh_token","expires_in":3600,"refresh_token_expires_in":3600}`)),
}
// The refresh call uses the same client, so it goes through mockRT too!
mockRT.On("RoundTrip", mock.MatchedBy(func(r *http.Request) bool {
return r.URL.String() == "https://github.com/login/oauth/access_token"
})).Return(respRefresh, nil).Once()
// 3. Retry call with new token
resp200 := &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString("Success")),
}
mockRT.On("RoundTrip", mock.MatchedBy(func(r *http.Request) bool {
return r.Header.Get("Authorization") == "Bearer new_token" && r.URL.String() == "https://api.github.com/user"
})).Return(resp200, nil).Once()
// Execute
resp, err := rt.RoundTrip(req)
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "Success", string(body))
mockRT.AssertExpectations(t)
}
func TestRoundTripper401WithAppKeyRefresh(t *testing.T) {
mockRT := new(MockRoundTripper)
expiry := time.Now().Add(10 * time.Minute) // Not expired (proactive refresh won't trigger)
conn := &models.GithubConnection{
GithubConn: models.GithubConn{
GithubAccessToken: models.GithubAccessToken{
AccessToken: api.AccessToken{
Token: "old_app_token",
},
},
TokenExpiresAt: &expiry,
},
}
// Use tokens slice so GetToken returns the current token
conn.UpdateToken("old_app_token", "", &expiry, nil)
// refreshFn simulates minting a new installation token
refreshCalled := 0
tp := &TokenProvider{
conn: conn,
refreshFn: func(tp *TokenProvider) errors.Error {
refreshCalled++
newExpiry := time.Now().Add(1 * time.Hour)
tp.conn.UpdateToken("new_app_token", "", &newExpiry, nil)
return nil
},
}
rt := NewRefreshRoundTripper(mockRT, tp)
req, _ := http.NewRequest("GET", "https://api.github.com/repos/test/test", nil)
// 1. First call returns 401
resp401 := &http.Response{
StatusCode: 401,
Body: io.NopCloser(bytes.NewBufferString("Bad credentials")),
}
mockRT.On("RoundTrip", mock.MatchedBy(func(r *http.Request) bool {
return r.Header.Get("Authorization") == "Bearer old_app_token"
})).Return(resp401, nil).Once()
// 2. Retry call with new token (after refreshFn runs)
resp200 := &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"full_name":"test/test"}`)),
}
mockRT.On("RoundTrip", mock.MatchedBy(func(r *http.Request) bool {
return r.Header.Get("Authorization") == "Bearer new_app_token"
})).Return(resp200, nil).Once()
// Execute
resp, err := rt.RoundTrip(req)
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, 1, refreshCalled, "refreshFn should have been called exactly once")
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, `{"full_name":"test/test"}`, string(body))
mockRT.AssertExpectations(t)
}
// TestProactiveRefreshNoDeadlock verifies that when the RefreshRoundTripper wraps
// the same http.Client's transport, a proactive token refresh does not deadlock.
// This reproduces the real-world scenario: GetToken() holds the mutex, calls
// refreshGitHubAppInstallationToken, which makes an HTTP request. If that request
// goes through the RefreshRoundTripper (re-entering GetToken), it would deadlock.
// The fix is that the refresh uses baseTransport directly.
func TestProactiveRefreshNoDeadlock(t *testing.T) {
// Set up a mock transport that will serve both:
// 1. The installation token refresh POST
// 2. The actual API GET after refresh
mockRT := new(MockRoundTripper)
client := &http.Client{Transport: mockRT}
// Token is expired — proactive refresh WILL trigger on GetToken()
expired := time.Now().Add(-1 * time.Minute)
conn := &models.GithubConnection{
GithubConn: models.GithubConn{
RestConnection: api.RestConnection{
Endpoint: "https://api.github.com/",
},
MultiAuth: api.MultiAuth{
AuthMethod: models.AppKey,
},
GithubAccessToken: models.GithubAccessToken{
AccessToken: api.AccessToken{
Token: "expired_ghs_token",
},
},
GithubAppKey: models.GithubAppKey{
AppKey: api.AppKey{
AppId: "12345",
SecretKey: testRSAKey,
},
InstallationID: 99999,
},
TokenExpiresAt: &expired,
},
}
conn.UpdateToken("expired_ghs_token", "", &expired, nil)
// Create the TokenProvider with baseTransport = mockRT (the unwrapped transport).
// This is what NewAppInstallationTokenProvider does: it captures client.Transport
// BEFORE the caller wraps it with RefreshRoundTripper.
tp := NewAppInstallationTokenProvider(conn, nil, client, nil, "")
// Now wrap the client's transport with RefreshRoundTripper (simulating what
// CreateApiClient does). After this, client.Transport = RefreshRoundTripper,
// but tp.baseTransport still points to mockRT.
rt := NewRefreshRoundTripper(mockRT, tp)
client.Transport = rt
// Mock: installation token refresh POST (goes through baseTransport, not RT)
newExpiry := time.Now().Add(1 * time.Hour)
installTokenBody := `{"token":"new_ghs_token","expires_at":"` + newExpiry.Format(time.RFC3339) + `"}`
mockRT.On("RoundTrip", mock.MatchedBy(func(r *http.Request) bool {
return r.Method == "POST" && r.URL.Path == "/app/installations/99999/access_tokens"
})).Return(&http.Response{
StatusCode: 201,
Body: io.NopCloser(bytes.NewBufferString(installTokenBody)),
}, nil).Once()
// Mock: the actual API request (goes through RefreshRoundTripper → GetToken → mockRT)
mockRT.On("RoundTrip", mock.MatchedBy(func(r *http.Request) bool {
return r.Method == "GET" && r.URL.Path == "/repos/test/test"
})).Return(&http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"full_name":"test/test"}`)),
}, nil).Once()
// Execute through the RefreshRoundTripper with a timeout to detect deadlocks.
done := make(chan struct{})
var resp *http.Response
var reqErr error
go func() {
req, _ := http.NewRequest("GET", "https://api.github.com/repos/test/test", nil)
resp, reqErr = rt.RoundTrip(req)
close(done)
}()
select {
case <-done:
// Success — no deadlock
case <-time.After(5 * time.Second):
t.Fatal("DEADLOCK: RoundTrip did not complete within 5 seconds — " +
"the refresh call is likely going through RefreshRoundTripper instead of baseTransport")
}
assert.NoError(t, reqErr)
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, "new_ghs_token", conn.Token, "token should have been refreshed")
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, `{"full_name":"test/test"}`, string(body))
mockRT.AssertExpectations(t)
}