256 lines
10 KiB
Go
256 lines
10 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/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 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)
|
|
}
|
|
}
|
|
}
|
|
}
|