773 lines
33 KiB
Go
773 lines
33 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
|
)
|
|
|
|
func TestCrossPlatformCoverageClassifyRefreshFailureUsesStructuredSignals(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want RefreshFailureClass
|
|
}{
|
|
{name: "deadline", err: context.DeadlineExceeded, want: RefreshFailureTransient},
|
|
{name: "network", err: &url.Error{Op: "Post", URL: "https://oauth.test", Err: context.DeadlineExceeded}, want: RefreshFailureTransient},
|
|
{name: "request timeout", err: &HTTPStatusError{StatusCode: http.StatusRequestTimeout}, want: RefreshFailureTransient},
|
|
{name: "rate limited", err: &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, want: RefreshFailureTransient},
|
|
{name: "server unavailable", err: &HTTPStatusError{StatusCode: http.StatusServiceUnavailable}, want: RefreshFailureTransient},
|
|
{name: "refresh rejected", err: &HTTPStatusError{StatusCode: http.StatusUnauthorized}, want: RefreshFailureTerminal},
|
|
{name: "invalid grant", err: &HTTPStatusError{StatusCode: http.StatusBadRequest}, want: RefreshFailureTerminal},
|
|
{name: "forbidden", err: &HTTPStatusError{StatusCode: http.StatusForbidden}, want: RefreshFailureTerminal},
|
|
{name: "local persistence", err: errors.New("save refreshed token failed"), want: RefreshFailureUnknown},
|
|
{name: "nil error", err: nil, want: RefreshFailureUnknown},
|
|
{name: "dns failure", err: &net.DNSError{Err: "no such host", Name: "oauth.test"}, want: RefreshFailureTransient},
|
|
{name: "redirect status", err: &HTTPStatusError{StatusCode: http.StatusFound}, want: RefreshFailureUnknown},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := ClassifyRefreshFailure(tt.err); got != tt.want {
|
|
t.Fatalf("ClassifyRefreshFailure() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageHTTPStatusErrorRetainsStatusThroughWrapping(t *testing.T) {
|
|
want := &HTTPStatusError{StatusCode: http.StatusTooManyRequests}
|
|
err := errors.Join(errors.New("refresh failed"), want)
|
|
if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient {
|
|
t.Fatalf("ClassifyRefreshFailure() = %q, want transient", got)
|
|
}
|
|
var statusErr *HTTPStatusError
|
|
if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusTooManyRequests {
|
|
t.Fatalf("HTTP status error not retained: %v", err)
|
|
}
|
|
if got, want := statusErr.Error(), "HTTP 429"; got != want {
|
|
t.Fatalf("HTTP status error = %q, want %q", got, want)
|
|
}
|
|
var nilStatus *HTTPStatusError
|
|
if got, want := nilStatus.Error(), "OAuth endpoint request failed"; got != want {
|
|
t.Fatalf("nil HTTP status error = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageOAuthExchangeDisplayErrorFallsBackToPlainError(t *testing.T) {
|
|
if got, want := oauthExchangeDisplayError(&HTTPStatusError{StatusCode: http.StatusBadGateway}), "HTTP 502: token exchange failed"; got != want {
|
|
t.Fatalf("status display error = %q, want %q", got, want)
|
|
}
|
|
if got, want := oauthExchangeDisplayError(errors.New("exchange failed")), "exchange failed"; got != want {
|
|
t.Fatalf("plain display error = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePostJSONClassifiesStatusWithoutLoggingResponseBody(t *testing.T) {
|
|
const secretBody = `{"refreshToken":"must-not-reach-logs"}`
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
_, _ = w.Write([]byte(secretBody))
|
|
}))
|
|
defer server.Close()
|
|
|
|
provider := &OAuthProvider{httpClient: server.Client()}
|
|
_, err := provider.postJSON(context.Background(), server.URL, map[string]string{"grantType": "refresh_token"})
|
|
if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient {
|
|
t.Fatalf("ClassifyRefreshFailure() = %q, want transient: %v", got, err)
|
|
}
|
|
if strings.Contains(err.Error(), "must-not-reach-logs") {
|
|
t.Fatalf("postJSON error leaked response body: %v", err)
|
|
}
|
|
if got := httpStatusResponseBody(err); !strings.Contains(got, "must-not-reach-logs") {
|
|
t.Fatalf("postJSON did not retain bounded response details for internal classification: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientRefreshFailures(t *testing.T) {
|
|
oldLoad := oauthLoadToken
|
|
oldLoadLocked := oauthLoadTokenLocked
|
|
oldAcquire := oauthAcquireLock
|
|
oldRefresh := oauthRefreshToken
|
|
oldMark := oauthMarkProfile
|
|
oldEdition := edition.Get()
|
|
t.Cleanup(func() {
|
|
oauthLoadToken = oldLoad
|
|
oauthLoadTokenLocked = oldLoadLocked
|
|
oauthAcquireLock = oldAcquire
|
|
oauthRefreshToken = oldRefresh
|
|
oauthMarkProfile = oldMark
|
|
edition.Override(oldEdition)
|
|
})
|
|
edition.Override(&edition.Hooks{})
|
|
|
|
expired := &TokenData{
|
|
AccessToken: "expired-access",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshToken: "refresh",
|
|
RefreshExpAt: time.Now().Add(time.Hour),
|
|
CorpID: "corp",
|
|
UserID: "user",
|
|
}
|
|
oauthLoadToken = func(string) (*TokenData, error) { return expired, nil }
|
|
oauthLoadTokenLocked = func(string, string) (*TokenData, error) { return expired, nil }
|
|
oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
|
|
|
|
markCalls := 0
|
|
oauthMarkProfile = func(_, _, status string) error {
|
|
if status != ProfileStatusExpired {
|
|
t.Fatalf("profile status = %q, want %q", status, ProfileStatusExpired)
|
|
}
|
|
markCalls++
|
|
return nil
|
|
}
|
|
provider := NewOAuthProvider(t.TempDir(), nil)
|
|
|
|
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
|
return nil, &HTTPStatusError{StatusCode: http.StatusServiceUnavailable}
|
|
}
|
|
if _, err := provider.GetTokenSnapshot(context.Background()); ClassifyRefreshFailure(err) != RefreshFailureTransient {
|
|
t.Fatalf("transient refresh error = %v", err)
|
|
}
|
|
if markCalls != 0 {
|
|
t.Fatalf("transient refresh marked profile expired %d times", markCalls)
|
|
}
|
|
|
|
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
|
return nil, &HTTPStatusError{StatusCode: http.StatusUnauthorized}
|
|
}
|
|
if _, err := provider.GetTokenSnapshot(context.Background()); ClassifyRefreshFailure(err) != RefreshFailureTerminal {
|
|
t.Fatalf("terminal refresh error = %v", err)
|
|
}
|
|
if markCalls != 1 {
|
|
t.Fatalf("terminal refresh marked profile expired %d times, want 1", markCalls)
|
|
}
|
|
|
|
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
|
return nil, &MCPTokenExchangeError{
|
|
Code: legacyMCPRefreshRejectedCode,
|
|
Message: "不合法的临时授权码",
|
|
}
|
|
}
|
|
_, err := provider.GetTokenSnapshot(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "dws auth login") ||
|
|
!strings.Contains(err.Error(), "--profile") ||
|
|
!strings.Contains(err.Error(), `profile: "corp:user"`) ||
|
|
strings.Contains(err.Error(), `dws auth login --profile "corp:user"`) ||
|
|
!strings.Contains(err.Error(), legacyMCPRefreshRejectedCode) {
|
|
t.Fatalf("legacy MCP refresh guidance = %v", err)
|
|
}
|
|
var exchangeErr *MCPTokenExchangeError
|
|
if !errors.As(err, &exchangeErr) || !exchangeErr.requiresReauthorization() {
|
|
t.Fatalf("legacy MCP refresh cause was not preserved: %v", err)
|
|
}
|
|
if markCalls != 2 {
|
|
t.Fatalf("legacy MCP rejection marked profile expired %d times, want 2", markCalls)
|
|
}
|
|
|
|
SetRuntimeProfile("External Worker")
|
|
_, err = provider.GetTokenSnapshot(context.Background())
|
|
SetRuntimeProfile("")
|
|
if err == nil || !strings.Contains(err.Error(), "dws auth login") ||
|
|
!strings.Contains(err.Error(), `profile: "External Worker"`) ||
|
|
strings.Contains(err.Error(), `dws auth login --profile "External Worker"`) {
|
|
t.Fatalf("legacy MCP refresh guidance did not isolate spaced selector as display data: %v", err)
|
|
}
|
|
if markCalls != 3 {
|
|
t.Fatalf("spaced legacy MCP rejection marked profile expired %d times, want 3", markCalls)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageLegacyGlobalSlotRecoversRejectedIdentityRefresh(t *testing.T) {
|
|
selected := &TokenData{
|
|
AccessToken: "expired-identity-access",
|
|
RefreshToken: "rejected-identity-refresh",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(time.Hour),
|
|
CorpID: "corp-v1039",
|
|
UserID: "user-v1039",
|
|
UserName: "V1039 User",
|
|
Source: "mcp",
|
|
}
|
|
legacy := &TokenData{
|
|
AccessToken: "valid-legacy-global-access",
|
|
RefreshToken: "valid-legacy-global-refresh",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
CorpID: selected.CorpID,
|
|
Source: "mcp",
|
|
}
|
|
|
|
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
|
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
|
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
|
return nil, &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
|
})
|
|
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
|
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
|
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
|
|
Name: "V1039 User",
|
|
CorpID: selected.CorpID,
|
|
UserID: selected.UserID,
|
|
UserName: selected.UserName,
|
|
}}}, nil
|
|
})
|
|
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
|
var saved *TokenData
|
|
testseam.Swap(t, &oauthSaveTokenLocked, func(_ string, data *TokenData) error {
|
|
copy := *data
|
|
saved = ©
|
|
return nil
|
|
})
|
|
|
|
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("lockedRefresh() error = %v", err)
|
|
}
|
|
if recovered.AccessToken != legacy.AccessToken || recovered.RefreshToken != legacy.RefreshToken {
|
|
t.Fatalf("recovered token = %#v, want legacy credential material %#v", recovered, legacy)
|
|
}
|
|
if recovered.UserID != selected.UserID || recovered.UserName != selected.UserName {
|
|
t.Fatalf("recovered identity = %q/%q, want selected identity %q/%q", recovered.UserID, recovered.UserName, selected.UserID, selected.UserName)
|
|
}
|
|
if saved == nil || saved.AccessToken != recovered.AccessToken || saved.UserID != selected.UserID {
|
|
t.Fatalf("saved recovery token = %#v, want recovered identity token %#v", saved, recovered)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountCorp(t *testing.T) {
|
|
selected := &TokenData{
|
|
AccessToken: "expired-identity-access",
|
|
RefreshToken: "rejected-identity-refresh",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(time.Hour),
|
|
CorpID: "corp-v1039-multi",
|
|
UserID: "user-v1039-a",
|
|
Source: "mcp",
|
|
}
|
|
legacy := &TokenData{
|
|
AccessToken: "valid-legacy-global-access",
|
|
RefreshToken: "valid-legacy-global-refresh",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
CorpID: selected.CorpID,
|
|
Source: "mcp",
|
|
}
|
|
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
|
|
|
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
|
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
|
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
|
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
|
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
|
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
|
|
{Name: "User A", CorpID: selected.CorpID, UserID: selected.UserID},
|
|
{Name: "User B", CorpID: selected.CorpID, UserID: "user-v1039-b"},
|
|
}}, nil
|
|
})
|
|
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
|
saved := false
|
|
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
|
saved = true
|
|
return nil
|
|
})
|
|
|
|
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
|
if !errors.Is(err, rejection) {
|
|
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
|
}
|
|
if saved {
|
|
t.Fatal("legacy global recovery saved a blank-user token for a multi-account organization")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankSelectedUserIDForMultiAccountCorp(t *testing.T) {
|
|
selected := &TokenData{
|
|
AccessToken: "expired-identity-access",
|
|
RefreshToken: "rejected-identity-refresh",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(time.Hour),
|
|
CorpID: "corp-v1039-blank-selected",
|
|
Source: "mcp",
|
|
}
|
|
legacy := &TokenData{
|
|
AccessToken: "valid-legacy-global-access",
|
|
RefreshToken: "valid-legacy-global-refresh",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
CorpID: selected.CorpID,
|
|
Source: "mcp",
|
|
}
|
|
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
|
|
|
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
|
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
|
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
|
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
|
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
|
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
|
|
{Name: "Blank A", CorpID: selected.CorpID, UserID: ""},
|
|
{Name: "Blank B", CorpID: selected.CorpID, UserID: ""},
|
|
}}, nil
|
|
})
|
|
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
|
saved := false
|
|
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
|
saved = true
|
|
return nil
|
|
})
|
|
|
|
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
|
if !errors.Is(err, rejection) {
|
|
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
|
}
|
|
if saved {
|
|
t.Fatal("legacy global recovery saved a blank-selected token for a multi-account organization")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) {
|
|
selected := &TokenData{
|
|
AccessToken: "expired-identity-access",
|
|
RefreshToken: "rejected-identity-refresh",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(time.Hour),
|
|
CorpID: "corp-v1039-user-mismatch",
|
|
UserID: "user-v1039-selected",
|
|
Source: "mcp",
|
|
}
|
|
legacy := &TokenData{
|
|
AccessToken: "valid-legacy-global-access",
|
|
RefreshToken: "valid-legacy-global-refresh",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
CorpID: selected.CorpID,
|
|
UserID: "user-v1039-other",
|
|
Source: "mcp",
|
|
}
|
|
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
|
|
|
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
|
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
|
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
|
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
|
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
|
saved := false
|
|
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
|
saved = true
|
|
return nil
|
|
})
|
|
|
|
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
|
if !errors.Is(err, rejection) {
|
|
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
|
}
|
|
if saved {
|
|
t.Fatal("legacy global recovery saved a token owned by a different user")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) {
|
|
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
|
expired := *fixture.blankToken
|
|
expired.ExpiresAt = time.Now().Add(-time.Hour)
|
|
expired.RefreshExpAt = time.Now().Add(time.Hour)
|
|
|
|
oldLoad := oauthLoadToken
|
|
oldLoadLocked := oauthLoadTokenLocked
|
|
oldAcquire := oauthAcquireLock
|
|
oldRefresh := oauthRefreshToken
|
|
oldMark := oauthMarkProfile
|
|
oldEdition := edition.Get()
|
|
t.Cleanup(func() {
|
|
oauthLoadToken = oldLoad
|
|
oauthLoadTokenLocked = oldLoadLocked
|
|
oauthAcquireLock = oldAcquire
|
|
oauthRefreshToken = oldRefresh
|
|
oauthMarkProfile = oldMark
|
|
edition.Override(oldEdition)
|
|
})
|
|
edition.Override(&edition.Hooks{})
|
|
oauthLoadToken = func(string) (*TokenData, error) { return &expired, nil }
|
|
oauthLoadTokenLocked = func(string, string) (*TokenData, error) { return &expired, nil }
|
|
oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
|
|
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
|
return nil, &MCPTokenExchangeError{
|
|
Code: legacyMCPRefreshRejectedCode,
|
|
Message: "legacy refresh rejected",
|
|
}
|
|
}
|
|
var markedSelector string
|
|
oauthMarkProfile = func(configDir, selector, status string) error {
|
|
markedSelector = selector
|
|
return MarkProfileStatus(configDir, selector, status)
|
|
}
|
|
|
|
provider := NewOAuthProvider(fixture.configDir, nil)
|
|
_, err := provider.GetTokenSnapshot(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "dws auth login") ||
|
|
!strings.Contains(err.Error(), "--profile") ||
|
|
!strings.Contains(err.Error(), "profile: "+strconv.Quote(fixture.blankSelector)) ||
|
|
strings.Contains(err.Error(), "dws auth login --profile") {
|
|
t.Fatalf("legacy blank refresh guidance = %v, want selector %q", err, fixture.blankSelector)
|
|
}
|
|
if markedSelector != fixture.blankSelector {
|
|
t.Fatalf("marked selector = %q, want blank %q", markedSelector, fixture.blankSelector)
|
|
}
|
|
cfg, loadErr := LoadProfiles(fixture.configDir)
|
|
if loadErr != nil {
|
|
t.Fatalf("LoadProfiles() error = %v", loadErr)
|
|
}
|
|
for _, profile := range cfg.Profiles {
|
|
switch profile.UserID {
|
|
case "":
|
|
if profile.Status != ProfileStatusExpired {
|
|
t.Fatalf("blank profile status = %q, want expired", profile.Status)
|
|
}
|
|
case fixture.exactUserID:
|
|
if profile.Status != ProfileStatusActive {
|
|
t.Fatalf("exact profile status = %q, want active", profile.Status)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSingleProfileIdentityMismatch(t *testing.T) {
|
|
selected := &TokenData{
|
|
AccessToken: "expired-identity-access",
|
|
RefreshToken: "rejected-identity-refresh",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(time.Hour),
|
|
CorpID: "corp-v1039-single-mismatch",
|
|
UserID: "user-v1039-selected",
|
|
Source: "mcp",
|
|
}
|
|
legacy := &TokenData{
|
|
AccessToken: "valid-legacy-global-access",
|
|
RefreshToken: "valid-legacy-global-refresh",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
CorpID: selected.CorpID,
|
|
Source: "mcp",
|
|
}
|
|
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
|
|
|
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
|
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
|
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
|
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
|
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
|
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
|
|
Name: "Other User",
|
|
CorpID: selected.CorpID,
|
|
UserID: "user-v1039-other",
|
|
}}}, nil
|
|
})
|
|
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
|
saved := false
|
|
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
|
saved = true
|
|
return nil
|
|
})
|
|
|
|
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
|
if !errors.Is(err, rejection) {
|
|
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
|
}
|
|
if saved {
|
|
t.Fatal("legacy global recovery saved a blank-user token whose single profile identity does not match")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageLegacyGlobalSlotRefreshesExpiredLegacyCredential(t *testing.T) {
|
|
selected := &TokenData{
|
|
AccessToken: "expired-identity-access",
|
|
RefreshToken: "rejected-identity-refresh",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(time.Hour),
|
|
CorpID: "corp-v1039-legacy-refresh",
|
|
UserID: "user-v1039",
|
|
UserName: "V1039 User",
|
|
Source: "mcp",
|
|
}
|
|
legacy := &TokenData{
|
|
AccessToken: "expired-legacy-global-access",
|
|
RefreshToken: "valid-legacy-global-refresh",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
CorpID: selected.CorpID,
|
|
UserID: selected.UserID,
|
|
Source: "mcp",
|
|
}
|
|
refreshed := &TokenData{
|
|
AccessToken: "refreshed-legacy-access",
|
|
RefreshToken: "rotated-legacy-refresh",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
CorpID: selected.CorpID,
|
|
UserID: selected.UserID,
|
|
Source: "mcp",
|
|
}
|
|
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
|
|
|
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
|
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
|
refreshCalls := 0
|
|
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
|
refreshCalls++
|
|
if refreshCalls == 1 {
|
|
return nil, rejection
|
|
}
|
|
return refreshed, nil
|
|
})
|
|
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
|
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
|
|
|
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("lockedRefresh() error = %v", err)
|
|
}
|
|
if refreshCalls != 2 {
|
|
t.Fatalf("oauthRefreshToken called %d times, want 2 (identity rejection + legacy refresh)", refreshCalls)
|
|
}
|
|
if recovered.AccessToken != refreshed.AccessToken {
|
|
t.Fatalf("recovered access token = %q, want refreshed legacy credential %q", recovered.AccessToken, refreshed.AccessToken)
|
|
}
|
|
if recovered.RefreshToken != refreshed.RefreshToken {
|
|
t.Fatalf("recovered refresh token = %q, want rotated credential %q", recovered.RefreshToken, refreshed.RefreshToken)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsProfilesLoadError(t *testing.T) {
|
|
selected := &TokenData{
|
|
AccessToken: "expired-identity-access",
|
|
RefreshToken: "rejected-identity-refresh",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(time.Hour),
|
|
CorpID: "corp-v1039-profiles-error",
|
|
UserID: "user-v1039",
|
|
Source: "mcp",
|
|
}
|
|
legacy := &TokenData{
|
|
AccessToken: "valid-legacy-global-access",
|
|
RefreshToken: "valid-legacy-global-refresh",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
CorpID: selected.CorpID,
|
|
Source: "mcp",
|
|
}
|
|
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
|
|
|
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
|
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
|
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
|
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
|
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { return nil, errors.New("profiles read failed") })
|
|
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
|
saved := false
|
|
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
|
saved = true
|
|
return nil
|
|
})
|
|
|
|
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
|
if !errors.Is(err, rejection) {
|
|
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
|
}
|
|
if saved {
|
|
t.Fatal("legacy global recovery saved a blank-user token when profiles could not be loaded")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSameRefreshToken(t *testing.T) {
|
|
selected := &TokenData{
|
|
AccessToken: "expired-identity-access",
|
|
RefreshToken: "shared-rejected-refresh",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(time.Hour),
|
|
CorpID: "corp-v1039-same-refresh",
|
|
UserID: "user-v1039",
|
|
Source: "mcp",
|
|
}
|
|
legacy := &TokenData{
|
|
AccessToken: "expired-legacy-global-access",
|
|
RefreshToken: selected.RefreshToken,
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
CorpID: selected.CorpID,
|
|
UserID: selected.UserID,
|
|
Source: "mcp",
|
|
}
|
|
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
|
|
|
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
|
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
|
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
|
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
|
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
|
saved := false
|
|
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
|
saved = true
|
|
return nil
|
|
})
|
|
|
|
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
|
if !errors.Is(err, rejection) {
|
|
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
|
}
|
|
if saved {
|
|
t.Fatal("legacy global recovery saved a token holding the same rejected refresh_token")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsNilSelectedAndEmptyLegacy(t *testing.T) {
|
|
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
|
provider := NewOAuthProvider(t.TempDir(), nil)
|
|
|
|
// nil selected must be rejected before any dereference.
|
|
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), nil, rejection); !errors.Is(err, rejection) {
|
|
t.Fatalf("nil selected error = %v, want original rejection", err)
|
|
}
|
|
|
|
// A keychain load that returns (nil, nil) must be rejected before any dereference.
|
|
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return nil, nil })
|
|
selected := &TokenData{
|
|
CorpID: "corp-v1039-nil-legacy",
|
|
UserID: "user-v1039",
|
|
Source: "mcp",
|
|
}
|
|
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
|
t.Fatalf("nil legacy error = %v, want original rejection", err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsNonReauthorizationErrors(t *testing.T) {
|
|
provider := NewOAuthProvider(t.TempDir(), nil)
|
|
selected := &TokenData{CorpID: "corp-v1039-plain", UserID: "user-v1039", Source: "mcp"}
|
|
|
|
plainErr := errors.New("plain refresh failure")
|
|
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, plainErr); !errors.Is(err, plainErr) {
|
|
t.Fatalf("plain error = %v, want original plain failure", err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsSaveAndRefreshFailures(t *testing.T) {
|
|
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
|
selected := &TokenData{
|
|
CorpID: "corp-v1039-recovery-steps",
|
|
UserID: "user-v1039",
|
|
Source: "mcp",
|
|
}
|
|
|
|
t.Run("save_failure", func(t *testing.T) {
|
|
legacy := &TokenData{
|
|
AccessToken: "valid-legacy-access",
|
|
RefreshToken: "valid-legacy-refresh",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
CorpID: selected.CorpID,
|
|
UserID: selected.UserID,
|
|
Source: "mcp",
|
|
}
|
|
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
|
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { return errors.New("save failed") })
|
|
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
|
t.Fatalf("save failure error = %v, want original rejection", err)
|
|
}
|
|
})
|
|
|
|
t.Run("refresh_expired", func(t *testing.T) {
|
|
legacy := &TokenData{
|
|
AccessToken: "expired-legacy-access",
|
|
RefreshToken: "expired-legacy-refresh",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(-time.Hour),
|
|
CorpID: selected.CorpID,
|
|
UserID: selected.UserID,
|
|
Source: "mcp",
|
|
}
|
|
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
|
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
|
t.Fatalf("expired refresh error = %v, want original rejection", err)
|
|
}
|
|
})
|
|
|
|
t.Run("refresh_error", func(t *testing.T) {
|
|
legacy := &TokenData{
|
|
AccessToken: "expired-legacy-access",
|
|
RefreshToken: "valid-legacy-refresh",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
CorpID: selected.CorpID,
|
|
UserID: selected.UserID,
|
|
Source: "mcp",
|
|
}
|
|
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
|
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
|
return nil, errors.New("legacy refresh failed")
|
|
})
|
|
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
|
t.Fatalf("legacy refresh error = %v, want original rejection", err)
|
|
}
|
|
})
|
|
|
|
t.Run("preflight_error", func(t *testing.T) {
|
|
legacy := &TokenData{
|
|
AccessToken: "expired-legacy-access",
|
|
RefreshToken: "valid-legacy-refresh",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
CorpID: selected.CorpID,
|
|
UserID: selected.UserID,
|
|
Source: "mcp",
|
|
}
|
|
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
|
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { return nil, errors.New("read failed") })
|
|
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
|
t.Fatalf("preflight error = %v, want original rejection", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
selected := &TokenData{CorpID: "corp-v1039-candidate", UserID: "user-v1039"}
|
|
|
|
if legacyGlobalRefreshCandidateMatches(configDir, selected, nil) {
|
|
t.Fatal("nil legacy accepted")
|
|
}
|
|
if legacyGlobalRefreshCandidateMatches(configDir, selected, &TokenData{CorpID: "corp-other", UserID: selected.UserID}) {
|
|
t.Fatal("different corp accepted")
|
|
}
|
|
if legacyGlobalRefreshCandidateMatches(configDir, &TokenData{UserID: "user-v1039"}, &TokenData{UserID: "user-v1039"}) {
|
|
t.Fatal("blank selected corp accepted")
|
|
}
|
|
|
|
blankSelected := &TokenData{CorpID: selected.CorpID}
|
|
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
|
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{Name: "Blank User", CorpID: selected.CorpID}}}, nil
|
|
})
|
|
if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) {
|
|
t.Fatal("both blank user IDs should match only through the single-profile guard")
|
|
}
|
|
if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) {
|
|
t.Fatal("blank selected with non-blank legacy accepted")
|
|
}
|
|
|
|
if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) {
|
|
t.Fatal("blank corp accepted by single-profile check")
|
|
}
|
|
}
|