blob: a754ad63ce37136d4b33f7388c3d5742b2d2259e [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"
"os"
"sync"
"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"
)
type MockRoundTripper struct {
mock.Mock
}
func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
args := m.Called(req)
return args.Get(0).(*http.Response), args.Error(1)
}
func TestNeedsRefresh(t *testing.T) {
tp := &TokenProvider{
conn: &models.GithubConnection{
GithubConn: models.GithubConn{
RefreshToken: "refresh_token",
},
},
}
// Not expired, outside buffer
expiry1 := time.Now().Add(10 * time.Minute)
tp.conn.TokenExpiresAt = &expiry1
assert.False(t, tp.needsRefresh())
// Inside buffer
expiry2 := time.Now().Add(1 * time.Minute)
tp.conn.TokenExpiresAt = &expiry2
assert.True(t, tp.needsRefresh())
// Expired
expiry3 := time.Now().Add(-1 * time.Minute)
tp.conn.TokenExpiresAt = &expiry3
assert.True(t, tp.needsRefresh())
// No refresh token
tp.conn.RefreshToken = ""
assert.False(t, tp.needsRefresh())
}
func TestTokenProviderConcurrency(t *testing.T) {
mockRT := new(MockRoundTripper)
client := &http.Client{Transport: mockRT}
expired := time.Now().Add(-1 * time.Minute) // Expired
conn := &models.GithubConnection{
GithubConn: models.GithubConn{
RefreshToken: "refresh_token",
TokenExpiresAt: &expired,
GithubAppKey: models.GithubAppKey{
AppKey: api.AppKey{
AppId: "123",
SecretKey: "secret",
},
},
},
}
logger, _ := logruslog.NewDefaultLogger(logrus.New())
tp := NewTokenProvider(conn, nil, client, logger, "")
// Mock response for refresh
respBody := `{"access_token":"new_token","refresh_token":"new_refresh_token","expires_in":3600,"refresh_token_expires_in":3600}`
resp := &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(respBody)),
}
// Expect exactly one call
mockRT.On("RoundTrip", mock.Anything).Return(resp, nil).Once()
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
token, err := tp.GetToken()
assert.NoError(t, err)
assert.Equal(t, "new_token", token)
}()
}
wg.Wait()
mockRT.AssertExpectations(t)
}
func TestConfigurableBuffer(t *testing.T) {
os.Setenv("GITHUB_TOKEN_REFRESH_BUFFER_MINUTES", "10")
defer os.Unsetenv("GITHUB_TOKEN_REFRESH_BUFFER_MINUTES")
tp := &TokenProvider{
conn: &models.GithubConnection{
GithubConn: models.GithubConn{
RefreshToken: "refresh_token",
},
},
}
// 9 minutes remaining (inside 10m buffer)
expiry9 := time.Now().Add(9 * time.Minute)
tp.conn.TokenExpiresAt = &expiry9
assert.True(t, tp.needsRefresh())
// 11 minutes remaining (outside 10m buffer)
expiry11 := time.Now().Add(11 * time.Minute)
tp.conn.TokenExpiresAt = &expiry11
assert.False(t, tp.needsRefresh())
}
// fakeRefreshFn returns a refreshFn that updates the connection token to newToken
// and increments the call counter pointed to by count.
func fakeRefreshFn(newToken string, count *int) func(*TokenProvider) errors.Error {
return func(tp *TokenProvider) errors.Error {
*count++
newExpiry := time.Now().Add(1 * time.Hour)
tp.conn.UpdateToken(newToken, "", &newExpiry, nil)
return nil
}
}
func TestNeedsRefreshWithRefreshFn(t *testing.T) {
callCount := 0
tp := &TokenProvider{
conn: &models.GithubConnection{},
refreshFn: fakeRefreshFn("unused", &callCount),
}
// Token not expired — outside default 5m buffer
expiry1 := time.Now().Add(10 * time.Minute)
tp.conn.TokenExpiresAt = &expiry1
assert.False(t, tp.needsRefresh(), "should not refresh when token is 10m from expiry")
// Token inside 5m buffer
expiry2 := time.Now().Add(2 * time.Minute)
tp.conn.TokenExpiresAt = &expiry2
assert.True(t, tp.needsRefresh(), "should refresh when token is 2m from expiry")
// Token already expired
expiry3 := time.Now().Add(-1 * time.Minute)
tp.conn.TokenExpiresAt = &expiry3
assert.True(t, tp.needsRefresh(), "should refresh when token is expired")
// TokenExpiresAt is nil — can't determine expiry, don't refresh (401 fallback covers this)
tp.conn.TokenExpiresAt = nil
assert.False(t, tp.needsRefresh(), "should not refresh when TokenExpiresAt is nil")
// Provider with neither refreshFn nor RefreshToken — should never refresh
tp2 := &TokenProvider{
conn: &models.GithubConnection{},
}
expiry4 := time.Now().Add(-1 * time.Minute)
tp2.conn.TokenExpiresAt = &expiry4
assert.False(t, tp2.needsRefresh(), "should not refresh without refreshFn or RefreshToken")
}
func TestAppKeyGetTokenTriggersRefresh(t *testing.T) {
callCount := 0
expired := time.Now().Add(-1 * time.Minute)
conn := &models.GithubConnection{
GithubConn: models.GithubConn{
GithubAccessToken: models.GithubAccessToken{
AccessToken: api.AccessToken{
Token: "expired_token",
},
},
TokenExpiresAt: &expired,
},
}
tp := &TokenProvider{
conn: conn,
refreshFn: fakeRefreshFn("refreshed_app_token", &callCount),
}
token, err := tp.GetToken()
assert.NoError(t, err)
assert.Equal(t, "refreshed_app_token", token)
assert.Equal(t, 1, callCount, "refreshFn should have been called exactly once")
// Second call — token is now fresh, should not trigger refresh
token2, err := tp.GetToken()
assert.NoError(t, err)
assert.Equal(t, "refreshed_app_token", token2)
assert.Equal(t, 1, callCount, "refreshFn should not be called again for a fresh token")
}
func TestAppKeyForceRefresh(t *testing.T) {
callCount := 0
conn := &models.GithubConnection{
GithubConn: models.GithubConn{
GithubAccessToken: models.GithubAccessToken{
AccessToken: api.AccessToken{
Token: "old_app_token",
},
},
},
}
tp := &TokenProvider{
conn: conn,
refreshFn: fakeRefreshFn("new_app_token", &callCount),
}
// ForceRefresh with matching old token — should trigger refresh
err := tp.ForceRefresh("old_app_token")
assert.NoError(t, err)
assert.Equal(t, 1, callCount)
assert.Equal(t, "new_app_token", conn.Token)
// ForceRefresh with stale old token — token has already changed, should be a no-op
err = tp.ForceRefresh("old_app_token")
assert.NoError(t, err)
assert.Equal(t, 1, callCount, "should not refresh when token has already changed")
}