Some checks failed
codecov / upload (push) Failing after 13m59s
CodeQL / Analyze (push) Failing after 1s
govulncheck / govulncheck (.) (push) Failing after 1s
govulncheck / govulncheck (./cmd/protoc-gen-go-grpc) (push) Failing after 1s
govulncheck / govulncheck (./gcp/observability) (push) Failing after 1s
govulncheck / govulncheck (./security/advancedtls) (push) Failing after 1s
govulncheck / govulncheck (./stats/opencensus) (push) Failing after 1s
Testing / vet-proto (push) Failing after 1s
Testing / tests (arm, latest) (push) Waiting to run
Testing / extras (latest) (push) Failing after 1s
Testing / static checks (latest-1) (push) Failing after 1s
Testing / tests (-race, latest) (push) Failing after 1s
Testing / tests (i386, latest) (push) Failing after 1s
Testing / tests (latest) (push) Failing after 1s
Testing / tests (latest-1) (push) Failing after 1s
Lock Threads / lock (push) Failing after 1s
Stale bot / stale (push) Failing after 1s
556 lines
18 KiB
Go
556 lines
18 KiB
Go
/*
|
|
*
|
|
* Copyright 2025 gRPC authors.
|
|
*
|
|
* Licensed 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 jwt
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/internal/grpctest"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
const defaultTestTimeout = 5 * time.Second
|
|
|
|
type s struct {
|
|
grpctest.Tester
|
|
}
|
|
|
|
func Test(t *testing.T) {
|
|
grpctest.RunSubTests(t, s{})
|
|
}
|
|
|
|
func (s) TestNewTokenFileCallCredentialsValidFilepath(t *testing.T) {
|
|
creds, err := NewTokenFileCallCredentials("/path/to/token")
|
|
if err != nil {
|
|
t.Fatalf("NewTokenFileCallCredentials() unexpected error: %v", err)
|
|
}
|
|
if creds == nil {
|
|
t.Fatal("NewTokenFileCallCredentials() returned nil credentials")
|
|
}
|
|
}
|
|
|
|
func (s) TestNewTokenFileCallCredentialsMissingFilepath(t *testing.T) {
|
|
if _, err := NewTokenFileCallCredentials(""); err == nil {
|
|
t.Fatalf("NewTokenFileCallCredentials() expected error, got nil")
|
|
}
|
|
}
|
|
|
|
func (s) TestTokenFileCallCreds_RequireTransportSecurity(t *testing.T) {
|
|
creds, err := NewTokenFileCallCredentials("/path/to/token")
|
|
if err != nil {
|
|
t.Fatalf("NewTokenFileCallCredentials() failed: %v", err)
|
|
}
|
|
|
|
if !creds.RequireTransportSecurity() {
|
|
t.Error("RequireTransportSecurity() = false, want true")
|
|
}
|
|
}
|
|
|
|
func (s) TestTokenFileCallCreds_GetRequestMetadata(t *testing.T) {
|
|
now := time.Now().Truncate(time.Second)
|
|
tests := []struct {
|
|
name string
|
|
invalidTokenPath bool
|
|
tokenContent string
|
|
authInfo credentials.AuthInfo
|
|
wantCode codes.Code
|
|
wantMetadata map[string]string
|
|
}{
|
|
{
|
|
name: "valid_token_with_future_expiration",
|
|
tokenContent: createTestJWT(t, now.Add(time.Hour)),
|
|
authInfo: &testAuthInfo{secLevel: credentials.PrivacyAndIntegrity},
|
|
wantCode: codes.OK,
|
|
wantMetadata: map[string]string{"authorization": "Bearer " + createTestJWT(t, now.Add(time.Hour))},
|
|
},
|
|
{
|
|
name: "insufficient_security_level",
|
|
tokenContent: createTestJWT(t, now.Add(time.Hour)),
|
|
authInfo: &testAuthInfo{secLevel: credentials.NoSecurity},
|
|
wantCode: codes.Unknown, // http2Client.getCallAuthData actually transforms such errors into into Unauthenticated
|
|
},
|
|
{
|
|
name: "unreachable_token_file",
|
|
invalidTokenPath: true,
|
|
authInfo: &testAuthInfo{secLevel: credentials.PrivacyAndIntegrity},
|
|
wantCode: codes.Unavailable,
|
|
},
|
|
{
|
|
name: "empty_file",
|
|
tokenContent: "",
|
|
authInfo: &testAuthInfo{secLevel: credentials.PrivacyAndIntegrity},
|
|
wantCode: codes.Unauthenticated,
|
|
},
|
|
{
|
|
name: "malformed_JWT_token",
|
|
tokenContent: "bad contents",
|
|
authInfo: &testAuthInfo{secLevel: credentials.PrivacyAndIntegrity},
|
|
wantCode: codes.Unauthenticated,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var tokenFile string
|
|
if tt.invalidTokenPath {
|
|
tokenFile = "/does-not-exist"
|
|
} else {
|
|
tokenFile = writeTempFile(t, "token", tt.tokenContent)
|
|
}
|
|
creds, err := NewTokenFileCallCredentials(tokenFile)
|
|
if err != nil {
|
|
t.Fatalf("NewTokenFileCallCredentials() failed: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
|
defer cancel()
|
|
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
|
AuthInfo: tt.authInfo,
|
|
})
|
|
|
|
metadata, err := creds.GetRequestMetadata(ctx)
|
|
if gotCode := status.Code(err); gotCode != tt.wantCode {
|
|
t.Fatalf("GetRequestMetadata() = %v, want %v", gotCode, tt.wantCode)
|
|
}
|
|
|
|
if diff := cmp.Diff(tt.wantMetadata, metadata); diff != "" {
|
|
t.Errorf("GetRequestMetadata() returned unexpected metadata (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s) TestTokenFileCallCreds_TokenCaching(t *testing.T) {
|
|
token := createTestJWT(t, time.Now().Add(time.Hour))
|
|
tokenFile := writeTempFile(t, "token", token)
|
|
|
|
creds, err := NewTokenFileCallCredentials(tokenFile)
|
|
if err != nil {
|
|
t.Fatalf("NewTokenFileCallCredentials() failed: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
|
defer cancel()
|
|
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
|
AuthInfo: &testAuthInfo{secLevel: credentials.PrivacyAndIntegrity},
|
|
})
|
|
|
|
// First call should read from file.
|
|
metadata1, err := creds.GetRequestMetadata(ctx)
|
|
if err != nil {
|
|
t.Fatalf("First GetRequestMetadata() failed: %v", err)
|
|
}
|
|
wantMetadata := map[string]string{"authorization": "Bearer " + token}
|
|
if diff := cmp.Diff(wantMetadata, metadata1); diff != "" {
|
|
t.Errorf("First GetRequestMetadata() returned unexpected metadata (-want +got):\n%s", diff)
|
|
}
|
|
|
|
// Update the file with a different token.
|
|
newToken := createTestJWT(t, time.Now().Add(2*time.Hour))
|
|
if err := os.WriteFile(tokenFile, []byte(newToken), 0600); err != nil {
|
|
t.Fatalf("Failed to update token file: %v", err)
|
|
}
|
|
|
|
// Second call should return cached token (not the updated one).
|
|
metadata2, err := creds.GetRequestMetadata(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Second GetRequestMetadata() failed: %v", err)
|
|
}
|
|
|
|
if diff := cmp.Diff(metadata1, metadata2); diff != "" {
|
|
t.Errorf("Second GetRequestMetadata() returned unexpected metadata (-want +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
// testAuthInfo implements credentials.AuthInfo for testing.
|
|
type testAuthInfo struct {
|
|
secLevel credentials.SecurityLevel
|
|
}
|
|
|
|
func (t *testAuthInfo) AuthType() string {
|
|
return "test"
|
|
}
|
|
|
|
func (t *testAuthInfo) GetCommonAuthInfo() credentials.CommonAuthInfo {
|
|
return credentials.CommonAuthInfo{SecurityLevel: t.secLevel}
|
|
}
|
|
|
|
// Tests that cached token expiration is set to 30 seconds before actual token
|
|
// expiration.
|
|
// TODO: Refactor the test to avoid inspecting and mutating internal state.
|
|
func (s) TestTokenFileCallCreds_CacheExpirationIsBeforeTokenExpiration(t *testing.T) {
|
|
// Create token that expires in 2 hours.
|
|
tokenExp := time.Now().Truncate(time.Second).Add(2 * time.Hour)
|
|
token := createTestJWT(t, tokenExp)
|
|
tokenFile := writeTempFile(t, "token", token)
|
|
|
|
creds, err := NewTokenFileCallCredentials(tokenFile)
|
|
if err != nil {
|
|
t.Fatalf("NewTokenFileCallCredentials() failed: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
|
defer cancel()
|
|
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
|
AuthInfo: &testAuthInfo{secLevel: credentials.PrivacyAndIntegrity},
|
|
})
|
|
|
|
// Get token to trigger caching.
|
|
if _, err = creds.GetRequestMetadata(ctx); err != nil {
|
|
t.Fatalf("GetRequestMetadata() failed: %v", err)
|
|
}
|
|
|
|
// Verify cached expiration is 30 seconds before actual token expiration.
|
|
impl := creds.(*jwtTokenFileCallCreds)
|
|
impl.mu.Lock()
|
|
cachedExp := impl.cachedExpiry
|
|
impl.mu.Unlock()
|
|
|
|
wantExp := tokenExp.Add(-30 * time.Second)
|
|
if !cachedExp.Equal(wantExp) {
|
|
t.Errorf("Cache expiration = %v, want %v", cachedExp, wantExp)
|
|
}
|
|
}
|
|
|
|
// Tests that pre-emptive refresh is triggered within 1 minute of expiration.
|
|
// This is tested as follows:
|
|
// * A token which expires "soon" is created.
|
|
// * On the first call to GetRequestMetadata, the token will get loaded and returned.
|
|
// * Another token is created and overwrites the file.
|
|
// * On the second call we will still return the (valid) first token but also
|
|
// detect that a refresh needs to happen and trigger it.
|
|
// * On the third call we confirm the new token has been loaded and returned.
|
|
func (s) TestTokenFileCallCreds_PreemptiveRefreshIsTriggered(t *testing.T) {
|
|
// Create token that expires in 80 seconds (=> cache expires in ~50s).
|
|
// This ensures pre-emptive refresh triggers since 50s < the 1 minute check.
|
|
tokenExp := time.Now().Add(80 * time.Second)
|
|
expiringToken := createTestJWT(t, tokenExp)
|
|
tokenFile := writeTempFile(t, "token", expiringToken)
|
|
|
|
creds, err := NewTokenFileCallCredentials(tokenFile)
|
|
if err != nil {
|
|
t.Fatalf("NewTokenFileCallCredentials() failed: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
|
defer cancel()
|
|
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
|
AuthInfo: &testAuthInfo{secLevel: credentials.PrivacyAndIntegrity},
|
|
})
|
|
|
|
// First call should read from file synchronously.
|
|
metadata1, err := creds.GetRequestMetadata(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetRequestMetadata() failed: %v", err)
|
|
}
|
|
wantAuth1 := "Bearer " + expiringToken
|
|
gotAuth1 := metadata1["authorization"]
|
|
if gotAuth1 != wantAuth1 {
|
|
t.Fatalf("First call should return original token: got %q, want %q", gotAuth1, wantAuth1)
|
|
}
|
|
|
|
// Verify token was cached and confirm expectation that refresh should be
|
|
// triggered.
|
|
impl := creds.(*jwtTokenFileCallCreds)
|
|
impl.mu.Lock()
|
|
cacheExp := impl.cachedExpiry
|
|
tokenCached := impl.cachedAuthHeader != ""
|
|
shouldTriggerRefresh := time.Until(cacheExp) < preemptiveRefreshThreshold
|
|
impl.mu.Unlock()
|
|
|
|
if !tokenCached {
|
|
t.Fatal("Token should be cached after successful GetRequestMetadata")
|
|
}
|
|
|
|
if !shouldTriggerRefresh {
|
|
timeUntilExp := time.Until(cacheExp)
|
|
t.Fatalf("Cache expires in %v; test precondition requires that this triggers preemptive refresh", timeUntilExp)
|
|
}
|
|
|
|
// Create new token file with different expiration while refresh is
|
|
// happening.
|
|
newToken := createTestJWT(t, time.Now().Add(2*time.Hour))
|
|
if err := os.WriteFile(tokenFile, []byte(newToken), 0600); err != nil {
|
|
t.Fatalf("Failed to write updated token file: %v", err)
|
|
}
|
|
|
|
// Get token again - this call should trigger a refresh given that the first
|
|
// one was cached but expiring soon.
|
|
// However, the function should have returned right away with the current
|
|
// cached token because it is still valid and the preemptive refresh is
|
|
// meant to happen without blocking the RPC.
|
|
metadata2, err := creds.GetRequestMetadata(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Second GetRequestMetadata() failed: %v", err)
|
|
}
|
|
wantAuth2 := wantAuth1
|
|
gotAuth2 := metadata2["authorization"]
|
|
if gotAuth2 != wantAuth2 {
|
|
t.Fatalf("Second call should return the original token: got %q, want %q", gotAuth2, wantAuth2)
|
|
}
|
|
|
|
// Now should get the new token which was refreshed in the background.
|
|
wantAuth3 := "Bearer " + newToken
|
|
ctx, cancel = context.WithTimeout(context.Background(), defaultTestTimeout)
|
|
defer cancel()
|
|
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
|
AuthInfo: &testAuthInfo{secLevel: credentials.PrivacyAndIntegrity},
|
|
})
|
|
for ; ; <-time.After(time.Millisecond) {
|
|
if ctx.Err() != nil {
|
|
t.Fatal("Context deadline expired before pre-emptive refresh completed")
|
|
}
|
|
// If the newly returned metadata is different to the old one, verify
|
|
// that it matches the token from the updated file. If not, fail the
|
|
// test.
|
|
metadata3, err := creds.GetRequestMetadata(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Second GetRequestMetadata() failed: %v", err)
|
|
}
|
|
// Pre-emptive refresh not completed yet, try again.
|
|
gotAuth3 := metadata3["authorization"]
|
|
if gotAuth3 == gotAuth2 {
|
|
continue
|
|
}
|
|
if gotAuth3 != wantAuth3 {
|
|
t.Fatalf("Third call should return the new token: got %q, want %q", gotAuth3, wantAuth3)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// Tests that backoff behavior handles file read errors correctly.
|
|
// It has the following expectations:
|
|
// First call to GetRequestMetadata() fails with UNAVAILABLE due to a
|
|
// missing file.
|
|
// Second call to GetRequestMetadata() fails with UNAVAILABLE due backoff.
|
|
// Third call to GetRequestMetadata() fails with UNAVAILABLE due to retry.
|
|
// Fourth call to GetRequestMetadata() fails with UNAVAILABLE due to backoff
|
|
// even though file exists.
|
|
// Fifth call to GetRequestMetadata() succeeds after reading the file and
|
|
// backoff has expired.
|
|
// TODO: Refactor the test to avoid inspecting and mutating internal state.
|
|
func (s) TestTokenFileCallCreds_BackoffBehavior(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
nonExistentFile := filepath.Join(tempDir, "nonexistent")
|
|
|
|
creds, err := NewTokenFileCallCredentials(nonExistentFile)
|
|
if err != nil {
|
|
t.Fatalf("NewTokenFileCallCredentials() failed: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
|
defer cancel()
|
|
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
|
AuthInfo: &testAuthInfo{secLevel: credentials.PrivacyAndIntegrity},
|
|
})
|
|
|
|
// First call should fail with UNAVAILABLE.
|
|
beforeCallRetryTime := time.Now()
|
|
_, err = creds.GetRequestMetadata(ctx)
|
|
if err == nil {
|
|
t.Fatal("Expected error from nonexistent file")
|
|
}
|
|
if status.Code(err) != codes.Unavailable {
|
|
t.Fatalf("GetRequestMetadata() = %v, want UNAVAILABLE", status.Code(err))
|
|
}
|
|
|
|
// Verify error is cached internally.
|
|
impl := creds.(*jwtTokenFileCallCreds)
|
|
impl.mu.Lock()
|
|
retryAttempt := impl.retryAttempt
|
|
nextRetryTime := impl.nextRetryTime
|
|
impl.mu.Unlock()
|
|
|
|
if retryAttempt != 1 {
|
|
t.Errorf("Expected retry attempt to be 1, got %d", retryAttempt)
|
|
}
|
|
if !nextRetryTime.After(beforeCallRetryTime) {
|
|
t.Error("Next retry time should be set to a time after the first call")
|
|
}
|
|
|
|
// Second call should still return cached error and not retry.
|
|
// Set nextRetryTime far enough in the future to ensure that's the case.
|
|
impl.mu.Lock()
|
|
impl.nextRetryTime = time.Now().Add(1 * time.Minute)
|
|
wantNextRetryTime := impl.nextRetryTime
|
|
impl.mu.Unlock()
|
|
_, err = creds.GetRequestMetadata(ctx)
|
|
if err == nil {
|
|
t.Fatalf("creds.GetRequestMetadata() = %v, want non-nil", err)
|
|
}
|
|
if status.Code(err) != codes.Unavailable {
|
|
t.Fatalf("GetRequestMetadata() = %v, want cached UNAVAILABLE", status.Code(err))
|
|
}
|
|
|
|
impl.mu.Lock()
|
|
retryAttempt2 := impl.retryAttempt
|
|
nextRetryTime2 := impl.nextRetryTime
|
|
impl.mu.Unlock()
|
|
if !nextRetryTime2.Equal(wantNextRetryTime) {
|
|
t.Errorf("nextRetryTime should not change due to backoff. Got: %v, Want: %v", nextRetryTime2, wantNextRetryTime)
|
|
}
|
|
if retryAttempt2 != 1 {
|
|
t.Error("Retry attempt should not change due to backoff")
|
|
}
|
|
|
|
// Third call should retry but still fail with UNAVAILABLE.
|
|
// Set the backoff retry time in the past to allow next retry attempt.
|
|
impl.mu.Lock()
|
|
impl.nextRetryTime = time.Now().Add(-1 * time.Minute)
|
|
beforeCallRetryTime = impl.nextRetryTime
|
|
impl.mu.Unlock()
|
|
_, err = creds.GetRequestMetadata(ctx)
|
|
if err == nil {
|
|
t.Fatalf("creds.GetRequestMetadata() = %v, want non-nil", err)
|
|
}
|
|
if status.Code(err) != codes.Unavailable {
|
|
t.Fatalf("GetRequestMetadata() = %v, want cached UNAVAILABLE", status.Code(err))
|
|
}
|
|
|
|
impl.mu.Lock()
|
|
retryAttempt3 := impl.retryAttempt
|
|
nextRetryTime3 := impl.nextRetryTime
|
|
impl.mu.Unlock()
|
|
|
|
if !nextRetryTime3.After(beforeCallRetryTime) {
|
|
t.Error("nextRetryTime3 should have been updated after third call")
|
|
}
|
|
if retryAttempt3 != 2 {
|
|
t.Error("Expected retry attempt to increase after retry")
|
|
}
|
|
|
|
// Create valid token file.
|
|
validToken := createTestJWT(t, time.Now().Add(time.Hour))
|
|
if err := os.WriteFile(nonExistentFile, []byte(validToken), 0600); err != nil {
|
|
t.Fatalf("Failed to create valid token file: %v", err)
|
|
}
|
|
|
|
// Fourth call should still fail even though the file now exists due to backoff.
|
|
// Set nextRetryTime far enough in the future to ensure that's the case.
|
|
_, err = creds.GetRequestMetadata(ctx)
|
|
impl.mu.Lock()
|
|
impl.nextRetryTime = time.Now().Add(1 * time.Minute)
|
|
wantNextRetryTime = impl.nextRetryTime
|
|
impl.mu.Unlock()
|
|
if err == nil {
|
|
t.Fatalf("creds.GetRequestMetadata() = %v, want non-nil", err)
|
|
}
|
|
if status.Code(err) != codes.Unavailable {
|
|
t.Fatalf("GetRequestMetadata() = %v, want cached UNAVAILABLE", status.Code(err))
|
|
}
|
|
|
|
impl.mu.Lock()
|
|
retryAttempt4 := impl.retryAttempt
|
|
nextRetryTime4 := impl.nextRetryTime
|
|
impl.mu.Unlock()
|
|
|
|
if !nextRetryTime4.Equal(wantNextRetryTime) {
|
|
t.Errorf("nextRetryTime should not change due to backoff. Got: %v, Want: %v", nextRetryTime4, wantNextRetryTime)
|
|
}
|
|
if retryAttempt4 != retryAttempt3 {
|
|
t.Error("Retry attempt should not change due to backoff")
|
|
}
|
|
|
|
// Fifth call should succeed since the file now exists and the backoff has
|
|
// expired.
|
|
// Set the backoff retry time in the past to allow next retry attempt.
|
|
impl.mu.Lock()
|
|
impl.nextRetryTime = time.Now().Add(-1 * time.Minute)
|
|
impl.mu.Unlock()
|
|
_, err = creds.GetRequestMetadata(ctx)
|
|
if err != nil {
|
|
t.Fatalf("After creating valid token file, backoff should expire and trigger a token reload on the next RPC. GetRequestMetadata() should eventually succeed, but got: %v", err)
|
|
}
|
|
// If successful, verify error cache and backoff state were cleared.
|
|
impl.mu.Lock()
|
|
clearedErr := impl.cachedError
|
|
retryAttempt = impl.retryAttempt
|
|
nextRetryTime = impl.nextRetryTime
|
|
impl.mu.Unlock()
|
|
|
|
if clearedErr != nil {
|
|
t.Errorf("After successful retry, cached error should be cleared, got: %v", clearedErr)
|
|
}
|
|
if retryAttempt != 0 {
|
|
t.Errorf("After successful retry, retry attempt should be reset, got: %d", retryAttempt)
|
|
}
|
|
if !nextRetryTime.IsZero() {
|
|
t.Error("After successful retry, next retry time should be cleared")
|
|
}
|
|
}
|
|
|
|
// createTestJWT creates a test JWT token with the specified expiration.
|
|
func createTestJWT(t *testing.T, expiration time.Time) string {
|
|
t.Helper()
|
|
|
|
claims := map[string]any{}
|
|
if !expiration.IsZero() {
|
|
claims["exp"] = expiration.Unix()
|
|
}
|
|
|
|
header := map[string]any{
|
|
"typ": "JWT",
|
|
"alg": "HS256",
|
|
}
|
|
headerBytes, err := json.Marshal(header)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal header: %v", err)
|
|
}
|
|
|
|
claimsBytes, err := json.Marshal(claims)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal claims: %v", err)
|
|
}
|
|
|
|
headerB64 := base64.URLEncoding.EncodeToString(headerBytes)
|
|
claimsB64 := base64.URLEncoding.EncodeToString(claimsBytes)
|
|
|
|
// Remove padding for URL-safe base64
|
|
headerB64 = strings.TrimRight(headerB64, "=")
|
|
claimsB64 = strings.TrimRight(claimsB64, "=")
|
|
|
|
// For testing, we'll use a fake signature
|
|
signature := base64.URLEncoding.EncodeToString([]byte("fake_signature"))
|
|
signature = strings.TrimRight(signature, "=")
|
|
|
|
return fmt.Sprintf("%s.%s.%s", headerB64, claimsB64, signature)
|
|
}
|
|
|
|
func writeTempFile(t *testing.T, name, content string) string {
|
|
t.Helper()
|
|
tempDir := t.TempDir()
|
|
filePath := filepath.Join(tempDir, name)
|
|
if err := os.WriteFile(filePath, []byte(content), 0600); err != nil {
|
|
t.Fatalf("Failed to write temp file: %v", err)
|
|
}
|
|
return filePath
|
|
}
|