1235 lines
46 KiB
Go
1235 lines
46 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
|
)
|
|
|
|
type oauthLoginFixture struct {
|
|
configDir string
|
|
callbackBase string
|
|
provider *OAuthProvider
|
|
server *httptest.Server
|
|
exchangeEntered chan struct{}
|
|
exchangeRelease chan struct{}
|
|
statusEntered chan struct{}
|
|
statusRelease chan struct{}
|
|
statusCalls atomic.Int32
|
|
exchangeError atomic.Bool
|
|
}
|
|
|
|
type oauthLoginResult struct {
|
|
token *TokenData
|
|
err error
|
|
}
|
|
|
|
type oauthHTTPResult struct {
|
|
status int
|
|
body string
|
|
err error
|
|
}
|
|
|
|
const oauthTestWaitTimeout = 5 * time.Second
|
|
|
|
func isolateOAuthPersistence(t *testing.T) {
|
|
t.Helper()
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
cleanupKeychain(t)
|
|
}
|
|
|
|
func startOAuthLogin(t *testing.T, parent context.Context, f *oauthLoginFixture) <-chan oauthLoginResult {
|
|
t.Helper()
|
|
ctx, cancel := context.WithCancel(parent)
|
|
done := make(chan oauthLoginResult, 1)
|
|
finished := make(chan struct{})
|
|
go func() {
|
|
defer close(finished)
|
|
token, err := f.provider.Login(ctx, true)
|
|
done <- oauthLoginResult{token: token, err: err}
|
|
}()
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
select {
|
|
case <-finished:
|
|
case <-time.After(oauthTestWaitTimeout):
|
|
t.Errorf("OAuth Login goroutine did not stop after cancellation")
|
|
}
|
|
})
|
|
return done
|
|
}
|
|
|
|
func awaitOAuthLogin(t *testing.T, done <-chan oauthLoginResult) oauthLoginResult {
|
|
t.Helper()
|
|
select {
|
|
case result := <-done:
|
|
return result
|
|
case <-time.After(oauthTestWaitTimeout):
|
|
t.Fatal("timed out waiting for OAuth Login")
|
|
return oauthLoginResult{}
|
|
}
|
|
}
|
|
|
|
func waitOAuthSignal(t *testing.T, signal <-chan struct{}, done <-chan oauthLoginResult, name string) {
|
|
t.Helper()
|
|
select {
|
|
case <-signal:
|
|
case result := <-done:
|
|
t.Fatalf("OAuth Login returned before %s: token=%#v err=%v", name, result.token, result.err)
|
|
case <-time.After(oauthTestWaitTimeout):
|
|
t.Fatalf("timed out waiting for OAuth %s", name)
|
|
}
|
|
}
|
|
|
|
func closeOAuthRelease(ch chan struct{}) {
|
|
select {
|
|
case <-ch:
|
|
default:
|
|
close(ch)
|
|
}
|
|
}
|
|
|
|
func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauthLoginFixture {
|
|
t.Helper()
|
|
isolateOAuthPersistence(t)
|
|
SetClientID("")
|
|
SetClientSecret("")
|
|
resetClientIDFromMCP()
|
|
t.Cleanup(func() {
|
|
SetClientID("")
|
|
SetClientSecret("")
|
|
resetClientIDFromMCP()
|
|
})
|
|
|
|
f := &oauthLoginFixture{
|
|
exchangeEntered: make(chan struct{}),
|
|
exchangeRelease: make(chan struct{}),
|
|
statusEntered: make(chan struct{}),
|
|
statusRelease: make(chan struct{}),
|
|
}
|
|
var exchangeOnce sync.Once
|
|
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, ClientIDPath):
|
|
_ = json.NewEncoder(w).Encode(ClientIDResponse{Success: true, Result: "test-client"})
|
|
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
|
exchangeOnce.Do(func() {
|
|
close(f.exchangeEntered)
|
|
<-f.exchangeRelease
|
|
})
|
|
if f.exchangeError.Load() {
|
|
http.Error(w, "exchange failed", http.StatusBadRequest)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"accessToken": "access",
|
|
"refreshToken": "refresh",
|
|
"expiresIn": 7200,
|
|
"corpId": "corp-1",
|
|
"userId": "user-1",
|
|
})
|
|
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
|
call := f.statusCalls.Add(1)
|
|
if call == 1 {
|
|
close(f.statusEntered)
|
|
<-f.statusRelease
|
|
}
|
|
_ = json.NewEncoder(w).Encode(status(call))
|
|
case strings.HasSuffix(r.URL.Path, SuperAdminPath):
|
|
_ = json.NewEncoder(w).Encode(SuperAdminResponse{
|
|
Success: true,
|
|
Result: []SuperAdmin{{StaffID: "admin-1", Name: "Admin"}},
|
|
})
|
|
case strings.HasSuffix(r.URL.Path, SendCliAuthApplyPath):
|
|
_ = json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(f.server.Close)
|
|
t.Cleanup(func() {
|
|
closeOAuthRelease(f.exchangeRelease)
|
|
closeOAuthRelease(f.statusRelease)
|
|
})
|
|
f.configDir = setupMCPConfigDir(t, f.server.URL)
|
|
|
|
oldClient := oauthHTTPClient
|
|
oauthHTTPClient = f.server.Client()
|
|
t.Cleanup(func() { oauthHTTPClient = oldClient })
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.callbackBase = "http://" + listener.Addr().String()
|
|
oldListen := oauthListen
|
|
oauthListen = func(string, string) (net.Listener, error) { return listener, nil }
|
|
t.Cleanup(func() { oauthListen = oldListen })
|
|
|
|
f.provider = &OAuthProvider{
|
|
configDir: f.configDir,
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
Output: io.Discard,
|
|
httpClient: f.server.Client(),
|
|
NoBrowser: true,
|
|
}
|
|
return f
|
|
}
|
|
|
|
func httpGetBody(t *testing.T, rawURL string) (int, string) {
|
|
t.Helper()
|
|
result := getHTTPBody(rawURL)
|
|
if result.err != nil {
|
|
t.Fatalf("GET %s: %v", rawURL, result.err)
|
|
}
|
|
return result.status, result.body
|
|
}
|
|
|
|
func getHTTPBody(rawURL string) oauthHTTPResult {
|
|
client := &http.Client{Timeout: 2 * time.Second}
|
|
resp, err := client.Get(rawURL)
|
|
if err != nil {
|
|
return oauthHTTPResult{err: err}
|
|
}
|
|
defer resp.Body.Close()
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return oauthHTTPResult{err: err}
|
|
}
|
|
return oauthHTTPResult{status: resp.StatusCode, body: string(data)}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
|
})
|
|
|
|
loginDone := startOAuthLogin(t, context.Background(), f)
|
|
|
|
for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin-1", "/api/cliAuthEnabled"} {
|
|
_, body := httpGetBody(t, f.callbackBase+path)
|
|
if !strings.Contains(body, "授权尚未完成") {
|
|
t.Fatalf("pre-auth %s = %q", path, body)
|
|
}
|
|
}
|
|
_, body := httpGetBody(t, f.callbackBase+"/api/sendApply")
|
|
if !strings.Contains(body, "adminStaffId") {
|
|
t.Fatalf("missing admin response = %q", body)
|
|
}
|
|
_, body = httpGetBody(t, f.callbackBase+"/api/status")
|
|
if !strings.Contains(body, "test-client") {
|
|
t.Fatalf("initial status = %q", body)
|
|
}
|
|
if _, body = httpGetBody(t, f.callbackBase+"/success"); !strings.Contains(body, "<html") {
|
|
t.Fatalf("success page = %q", body)
|
|
}
|
|
|
|
callbackDone := make(chan oauthHTTPResult, 1)
|
|
go func() {
|
|
callbackDone <- getHTTPBody(f.callbackBase + CallbackPath + "?code=good")
|
|
}()
|
|
waitOAuthSignal(t, f.exchangeEntered, loginDone, "token exchange")
|
|
_, body = httpGetBody(t, f.callbackBase+CallbackPath+"?authCode=good")
|
|
if !strings.Contains(body, "正在处理授权") {
|
|
t.Fatalf("concurrent callback = %q", body)
|
|
}
|
|
closeOAuthRelease(f.exchangeRelease)
|
|
waitOAuthSignal(t, f.statusEntered, loginDone, "CLI auth status check")
|
|
|
|
_, body = httpGetBody(t, f.callbackBase+CallbackPath+"?code=good")
|
|
if !strings.Contains(body, "<html") {
|
|
t.Fatalf("cached callback = %q", body)
|
|
}
|
|
_, body = httpGetBody(t, f.callbackBase+CallbackPath)
|
|
if !strings.Contains(body, "<html") {
|
|
t.Fatalf("cached no-code callback = %q", body)
|
|
}
|
|
_, body = httpGetBody(t, f.callbackBase+"/api/superAdmin")
|
|
if !strings.Contains(body, "admin-1") {
|
|
t.Fatalf("super admins = %q", body)
|
|
}
|
|
_, body = httpGetBody(t, f.callbackBase+"/api/sendApply?adminStaffId=admin-1")
|
|
if !strings.Contains(body, "true") {
|
|
t.Fatalf("send apply = %q", body)
|
|
}
|
|
_, body = httpGetBody(t, f.callbackBase+"/api/status")
|
|
if !strings.Contains(body, "admin-1") || !strings.Contains(body, "true") {
|
|
t.Fatalf("updated status = %q", body)
|
|
}
|
|
_, body = httpGetBody(t, f.callbackBase+"/api/cliAuthEnabled")
|
|
if !strings.Contains(body, "cliAuthEnabled") {
|
|
t.Fatalf("auth enabled API = %q", body)
|
|
}
|
|
|
|
closeOAuthRelease(f.statusRelease)
|
|
select {
|
|
case callback := <-callbackDone:
|
|
if callback.err != nil || !strings.Contains(callback.body, "<html") {
|
|
t.Fatalf("callback body = %q, %v", callback.body, callback.err)
|
|
}
|
|
case <-time.After(oauthTestWaitTimeout):
|
|
t.Fatal("timed out waiting for OAuth callback")
|
|
}
|
|
result := awaitOAuthLogin(t, loginDone)
|
|
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
|
|
t.Fatalf("Login = %#v, %v", result.token, result.err)
|
|
}
|
|
|
|
if _, err := f.provider.Status(); err != nil {
|
|
t.Fatalf("Status: %v", err)
|
|
}
|
|
if token, err := f.provider.GetAccessToken(context.Background()); err != nil || token != "access" {
|
|
t.Fatalf("GetAccessToken = %q, %v", token, err)
|
|
}
|
|
if err := f.provider.Logout(); err != nil {
|
|
t.Fatalf("Logout: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOAuthForcedLoginIgnoresUnreadableUnrelatedProfile(t *testing.T) {
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
|
})
|
|
if err := SaveProfiles(f.configDir, &ProfilesConfig{
|
|
Version: profilesVersion,
|
|
Profiles: []Profile{{
|
|
Name: "unrelated",
|
|
CorpID: "corp-unrelated",
|
|
UserID: "user-unrelated",
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("SaveProfiles() error = %v", err)
|
|
}
|
|
|
|
oldGet := authKeychainGet
|
|
oldValidate := authValidateEntries
|
|
t.Cleanup(func() {
|
|
authKeychainGet = oldGet
|
|
authValidateEntries = oldValidate
|
|
})
|
|
var unrelatedReads atomic.Int32
|
|
var inventoryCalls atomic.Int32
|
|
authKeychainGet = func(service, account string) (string, error) {
|
|
if account == TokenAccountForCorpID("corp-unrelated") ||
|
|
account == TokenAccountForIdentity("corp-unrelated", "user-unrelated") {
|
|
unrelatedReads.Add(1)
|
|
return "", errors.New("unrelated profile ciphertext is unreadable")
|
|
}
|
|
return oldGet(service, account)
|
|
}
|
|
authValidateEntries = func(string) error {
|
|
inventoryCalls.Add(1)
|
|
return errors.New("unrelated orphan ciphertext is unreadable")
|
|
}
|
|
|
|
loginDone := startOAuthLogin(t, context.Background(), f)
|
|
callbackDone := make(chan oauthHTTPResult, 1)
|
|
go func() {
|
|
callbackDone <- getHTTPBody(f.callbackBase + CallbackPath + "?code=unrelated-safe")
|
|
}()
|
|
waitOAuthSignal(t, f.exchangeEntered, loginDone, "token exchange")
|
|
closeOAuthRelease(f.exchangeRelease)
|
|
waitOAuthSignal(t, f.statusEntered, loginDone, "CLI auth status check")
|
|
closeOAuthRelease(f.statusRelease)
|
|
|
|
select {
|
|
case callback := <-callbackDone:
|
|
if callback.err != nil || !strings.Contains(callback.body, "<html") {
|
|
t.Fatalf("OAuth callback body = %q, %v", callback.body, callback.err)
|
|
}
|
|
case <-time.After(oauthTestWaitTimeout):
|
|
t.Fatal("timed out waiting for OAuth callback")
|
|
}
|
|
result := awaitOAuthLogin(t, loginDone)
|
|
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
|
|
t.Fatalf("OAuthProvider.Login() = %#v, %v", result.token, result.err)
|
|
}
|
|
if got := unrelatedReads.Load(); got != 0 {
|
|
t.Fatalf("unrelated profile token reads = %d, want 0", got)
|
|
}
|
|
if got := inventoryCalls.Load(); got != 0 {
|
|
t.Fatalf("full inventory validation calls = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageOAuthLoginMissingCallbackCode(t *testing.T) {
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
|
})
|
|
closeOAuthRelease(f.exchangeRelease)
|
|
closeOAuthRelease(f.statusRelease)
|
|
done := startOAuthLogin(t, context.Background(), f)
|
|
status, body := httpGetBody(t, f.callbackBase+CallbackPath)
|
|
if status != http.StatusBadRequest || strings.TrimSpace(body) == "" {
|
|
t.Fatalf("missing callback = %d %q", status, body)
|
|
}
|
|
if result := awaitOAuthLogin(t, done); result.err == nil {
|
|
t.Fatal("missing callback code did not fail login")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageOAuthLoginEarlyAndListenerEdges(t *testing.T) {
|
|
isolateOAuthPersistence(t)
|
|
var buf bytes.Buffer
|
|
p := &OAuthProvider{configDir: t.TempDir(), Output: &buf}
|
|
if p.output() != &buf || (*OAuthProvider)(nil).output() != io.Discard {
|
|
t.Fatal("provider output selection failed")
|
|
}
|
|
|
|
valid := &TokenData{
|
|
AccessToken: "valid",
|
|
RefreshToken: "refresh",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
}
|
|
if err := SaveTokenData(p.configDir, valid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := p.Login(context.Background(), false)
|
|
if err != nil || got.AccessToken != "valid" {
|
|
t.Fatalf("silent Login = %#v, %v", got, err)
|
|
}
|
|
|
|
oldListen := oauthListen
|
|
t.Cleanup(func() { oauthListen = oldListen })
|
|
oauthListen = func(string, string) (net.Listener, error) { return nil, errors.New("listen") }
|
|
SetClientID("client")
|
|
SetClientSecret("secret")
|
|
t.Cleanup(func() {
|
|
SetClientID("")
|
|
SetClientSecret("")
|
|
})
|
|
if _, err := p.Login(context.Background(), true); err == nil || !strings.Contains(err.Error(), "listener") {
|
|
t.Fatalf("listener failure = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageOAuthLoginTimeoutAndServerError(t *testing.T) {
|
|
oldTimeout := oauthLoginTimeout
|
|
oldListen := oauthListen
|
|
t.Cleanup(func() {
|
|
oauthLoginTimeout = oldTimeout
|
|
oauthListen = oldListen
|
|
})
|
|
oauthLoginTimeout = time.Millisecond
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
|
})
|
|
close(f.exchangeRelease)
|
|
close(f.statusRelease)
|
|
if _, err := f.provider.Login(context.Background(), true); err == nil || !strings.Contains(strings.ToLower(err.Error()), "timeout") {
|
|
t.Fatalf("login timeout = %v", err)
|
|
}
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := listener.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oauthLoginTimeout = time.Second
|
|
oauthListen = func(string, string) (net.Listener, error) { return listener, nil }
|
|
if _, err := f.provider.Login(context.Background(), true); err == nil || !strings.Contains(err.Error(), "callback server") {
|
|
t.Fatalf("callback server failure = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageOAuthProviderOtherMethods(t *testing.T) {
|
|
isolateOAuthPersistence(t)
|
|
dir := t.TempDir()
|
|
_ = DeleteTokenDataKeychain()
|
|
t.Cleanup(func() { _ = DeleteTokenDataKeychain() })
|
|
p := &OAuthProvider{configDir: dir, Output: io.Discard}
|
|
if _, err := p.GetAccessToken(context.Background()); err == nil {
|
|
t.Fatal("missing access token unexpectedly succeeded")
|
|
}
|
|
expired := &TokenData{
|
|
AccessToken: "expired",
|
|
RefreshToken: "",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(-time.Hour),
|
|
CorpID: "corp-expired",
|
|
}
|
|
if err := SaveTokenData(dir, expired); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := p.GetAccessToken(context.Background()); err == nil {
|
|
t.Fatal("expired credentials unexpectedly succeeded")
|
|
}
|
|
if _, err := p.lockedRefresh(context.Background()); err == nil {
|
|
t.Fatal("expired refresh token unexpectedly succeeded")
|
|
}
|
|
|
|
p.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(
|
|
"{\"accessToken\":\"new\",\"refreshToken\":\"refresh\",\"expiresIn\":7200,\"corpId\":\"corp-new\"}",
|
|
)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
})}
|
|
SetClientID("direct-client")
|
|
SetClientSecret("direct-secret")
|
|
t.Cleanup(func() {
|
|
SetClientID("")
|
|
SetClientSecret("")
|
|
})
|
|
token, err := p.ExchangeAuthCode(context.Background(), "code", "uid")
|
|
if err != nil || token.UserID != "uid" {
|
|
t.Fatalf("ExchangeAuthCode = %#v, %v", token, err)
|
|
}
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
return f(r)
|
|
}
|
|
|
|
func TestCrossPlatformCoverageOAuthPersistConfigEdges(t *testing.T) {
|
|
isolateOAuthPersistence(t)
|
|
p := &OAuthProvider{configDir: t.TempDir(), logger: slog.Default()}
|
|
SetClientID("")
|
|
SetClientSecret("")
|
|
p.persistAppConfigIfNeeded()
|
|
SetClientID(DefaultClientID)
|
|
SetClientSecret("secret")
|
|
p.persistAppConfigIfNeeded()
|
|
SetClientID("custom-client")
|
|
SetClientSecret("custom-secret")
|
|
p.persistAppConfigIfNeeded()
|
|
cfg, err := LoadAppConfig(p.configDir)
|
|
if err != nil || cfg.ClientID != "custom-client" {
|
|
t.Fatalf("persisted config = %#v, %v", cfg, err)
|
|
}
|
|
|
|
badDir := filepath.Join(t.TempDir(), "file")
|
|
if err := os.WriteFile(badDir, []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
(&OAuthProvider{configDir: badDir, logger: slog.Default()}).persistAppConfigIfNeeded()
|
|
SetClientID("")
|
|
SetClientSecret("")
|
|
}
|
|
|
|
func TestCrossPlatformCoverageBuildOAuthAuthURLTarget(t *testing.T) {
|
|
raw := buildAuthURL("client", "http://localhost/callback", "corp")
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if parsed.Query().Get("corpId") != "corp" {
|
|
t.Fatalf("auth URL = %q", raw)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
|
|
oldPoll := oauthPollInterval
|
|
oldApproval := oauthApprovalTimeout
|
|
oldPause := oauthSuccessPause
|
|
oldBrowser := oauthOpenBrowser
|
|
t.Cleanup(func() {
|
|
oauthPollInterval = oldPoll
|
|
oauthApprovalTimeout = oldApproval
|
|
oauthSuccessPause = oldPause
|
|
oauthOpenBrowser = oldBrowser
|
|
})
|
|
oauthPollInterval = time.Millisecond
|
|
oauthSuccessPause = 0
|
|
oauthOpenBrowser = func(string) error { return errors.New("browser") }
|
|
|
|
terminal := []struct {
|
|
name string
|
|
status CLIAuthStatus
|
|
}{
|
|
{"user forbidden", CLIAuthStatus{Success: true, Result: &CLIAuthResult{UserScope: "forbidden"}}},
|
|
{"user not allowed", CLIAuthStatus{Success: true, Result: &CLIAuthResult{UserScope: "specified"}}},
|
|
{"channel required", CLIAuthStatus{Success: true, Result: &CLIAuthResult{ChannelScope: "specified"}}},
|
|
{"enterprise message", CLIAuthStatus{Success: false, ErrorCode: "ENTERPRISE_NOT_AUTHORIZED", ErrorMsg: "enterprise denied"}},
|
|
}
|
|
for _, tt := range terminal {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return tt.status })
|
|
closeOAuthRelease(f.exchangeRelease)
|
|
closeOAuthRelease(f.statusRelease)
|
|
f.provider.NoBrowser = false
|
|
done := startOAuthLogin(t, context.Background(), f)
|
|
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=denied")
|
|
if result := awaitOAuthLogin(t, done); result.err == nil {
|
|
t.Fatal("denied login succeeded")
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("channel not allowed", func(t *testing.T) {
|
|
t.Setenv("DWS_CHANNEL", "blocked")
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{ChannelScope: "specified", AllowedChannels: []string{"allowed"}}}
|
|
})
|
|
closeOAuthRelease(f.exchangeRelease)
|
|
closeOAuthRelease(f.statusRelease)
|
|
done := startOAuthLogin(t, context.Background(), f)
|
|
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=denied")
|
|
if result := awaitOAuthLogin(t, done); result.err == nil {
|
|
t.Fatal("channel-denied login succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("approval timeout", func(t *testing.T) {
|
|
oauthApprovalTimeout = 2 * time.Millisecond
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}
|
|
})
|
|
closeOAuthRelease(f.exchangeRelease)
|
|
closeOAuthRelease(f.statusRelease)
|
|
done := startOAuthLogin(t, context.Background(), f)
|
|
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
|
|
if result := awaitOAuthLogin(t, done); result.err == nil {
|
|
t.Fatal("approval timeout login succeeded")
|
|
}
|
|
oauthApprovalTimeout = oldApproval
|
|
})
|
|
|
|
t.Run("poll becomes enabled", func(t *testing.T) {
|
|
f := newOAuthLoginFixture(t, func(call int32) CLIAuthStatus {
|
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: call > 1}}
|
|
})
|
|
closeOAuthRelease(f.exchangeRelease)
|
|
closeOAuthRelease(f.statusRelease)
|
|
done := startOAuthLogin(t, context.Background(), f)
|
|
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
|
|
if result := awaitOAuthLogin(t, done); result.err != nil {
|
|
t.Fatalf("poll-enabled login failed: %v", result.err)
|
|
}
|
|
})
|
|
|
|
t.Run("context canceled while pending", func(t *testing.T) {
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}
|
|
})
|
|
closeOAuthRelease(f.exchangeRelease)
|
|
closeOAuthRelease(f.statusRelease)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := startOAuthLogin(t, ctx, f)
|
|
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
|
|
cancel()
|
|
if result := awaitOAuthLogin(t, done); result.err == nil {
|
|
t.Fatal("canceled pending login succeeded")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageOAuthLoginExchangeFailure(t *testing.T) {
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
|
})
|
|
f.exchangeError.Store(true)
|
|
closeOAuthRelease(f.exchangeRelease)
|
|
closeOAuthRelease(f.statusRelease)
|
|
done := startOAuthLogin(t, context.Background(), f)
|
|
_, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=bad")
|
|
if !strings.Contains(body, "failed") {
|
|
t.Fatalf("exchange failure page = %q", body)
|
|
}
|
|
if result := awaitOAuthLogin(t, done); result.err == nil {
|
|
t.Fatal("exchange failure login succeeded")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
|
|
isolateOAuthPersistence(t)
|
|
t.Setenv("DWS_CLIENT_ID", "")
|
|
t.Setenv("DWS_CLIENT_SECRET", "")
|
|
dir := t.TempDir()
|
|
p := &OAuthProvider{configDir: dir, Output: io.Discard}
|
|
oldOAuthClient := oauthHTTPClient
|
|
responseBody := "{\"accessToken\":\"new-access\",\"refreshToken\":\"new-refresh\",\"expiresIn\":7200,\"corpId\":\"new-corp\"}"
|
|
p.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(responseBody)), Header: make(http.Header)}, nil
|
|
})}
|
|
SetClientID("direct")
|
|
SetClientSecret("secret")
|
|
t.Cleanup(func() {
|
|
SetClientID("")
|
|
SetClientSecret("")
|
|
resetClientIDFromMCP()
|
|
oauthHTTPClient = oldOAuthClient
|
|
})
|
|
|
|
for name, body := range map[string][]byte{
|
|
"malformed": []byte("{"),
|
|
"missing": []byte("{}"),
|
|
} {
|
|
t.Run("direct "+name, func(t *testing.T) {
|
|
if _, err := p.parseTokenResponse(body); err == nil {
|
|
t.Fatal("invalid direct token parsed")
|
|
}
|
|
})
|
|
t.Run("mcp "+name, func(t *testing.T) {
|
|
if _, err := p.parseMCPTokenResponse(body); err == nil {
|
|
t.Fatal("invalid MCP token parsed")
|
|
}
|
|
})
|
|
}
|
|
if parsed, err := p.parseTokenResponse([]byte("{\"accessToken\":\"a\",\"expiresIn\":0}")); err != nil || parsed.ExpiresAt.IsZero() {
|
|
t.Fatalf("default direct expiry = %#v, %v", parsed, err)
|
|
}
|
|
if parsed, err := p.parseMCPTokenResponse([]byte("{\"accessToken\":\"a\",\"expiresIn\":0,\"corpName\":\"Corp\"}")); err != nil || parsed.CorpName != "Corp" {
|
|
t.Fatalf("default MCP expiry = %#v, %v", parsed, err)
|
|
}
|
|
|
|
if _, err := p.postJSON(context.Background(), "https://example.test", map[string]any{"bad": func() {}}); err == nil {
|
|
t.Fatal("unmarshalable request succeeded")
|
|
}
|
|
if _, err := p.postJSON(context.Background(), "://bad", map[string]string{}); err == nil {
|
|
t.Fatal("invalid request URL succeeded")
|
|
}
|
|
p.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return nil, errors.New("network")
|
|
})}
|
|
if _, err := p.postJSON(context.Background(), "https://example.test", map[string]string{}); err == nil {
|
|
t.Fatal("network request succeeded")
|
|
}
|
|
p.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(errorReader{}), Header: make(http.Header)}, nil
|
|
})}
|
|
if _, err := p.postJSON(context.Background(), "https://example.test", map[string]string{}); err == nil {
|
|
t.Fatal("response read failure succeeded")
|
|
}
|
|
p.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader(strings.Repeat("x", 250))), Header: make(http.Header)}, nil
|
|
})}
|
|
if _, err := p.postJSON(context.Background(), "https://example.test", map[string]string{}); err == nil {
|
|
t.Fatal("HTTP failure succeeded")
|
|
}
|
|
|
|
p.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(responseBody)), Header: make(http.Header)}, nil
|
|
})}
|
|
original := &TokenData{
|
|
ClientID: "direct", Source: "direct", RefreshToken: "refresh", PersistentCode: "pc",
|
|
CorpID: "corp", CorpName: "Original", UserID: "user", UserName: "Name",
|
|
}
|
|
if err := SaveClientSecret("direct", "secret"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
updated, err := p.refreshWithRefreshToken(context.Background(), original)
|
|
if err != nil || updated.CorpID != "corp" || updated.PersistentCode != "pc" || updated.CorpName != "Original" {
|
|
t.Fatalf("direct refresh = %#v, %v", updated, err)
|
|
}
|
|
missing := &TokenData{ClientID: "missing-client", RefreshToken: "refresh"}
|
|
SetClientID("")
|
|
SetClientSecret("")
|
|
if _, err := p.refreshWithRefreshToken(context.Background(), missing); err == nil {
|
|
t.Fatal("refresh without credentials succeeded")
|
|
}
|
|
|
|
mcpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, responseBody)
|
|
}))
|
|
defer mcpSrv.Close()
|
|
configDir := setupMCPConfigDir(t, mcpSrv.URL)
|
|
resetAppConfigCache()
|
|
oauthHTTPClient = mcpSrv.Client()
|
|
mcpProvider := &OAuthProvider{configDir: configDir, httpClient: mcpSrv.Client()}
|
|
mcpOriginal := &TokenData{
|
|
ClientID: "mcp-client", Source: "mcp", RefreshToken: "refresh",
|
|
CorpID: "corp", UserID: "user",
|
|
}
|
|
if updated, err := mcpProvider.refreshViaMCP(context.Background(), mcpOriginal); err != nil || updated.Source != "mcp" {
|
|
t.Fatalf("MCP refresh = %#v, %v", updated, err)
|
|
}
|
|
if _, err := mcpProvider.refreshViaMCP(context.Background(), &TokenData{Source: "mcp"}); err == nil {
|
|
t.Fatalf("MCP refresh without client ID succeeded (fallback client ID %q)", ClientID())
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) {
|
|
isolateOAuthPersistence(t)
|
|
oldLoad := oauthLoadToken
|
|
oldLoadLocked := oauthLoadTokenLocked
|
|
oldAcquire := oauthAcquireLock
|
|
oldMark := oauthMarkProfile
|
|
oldFetch := oauthFetchClientID
|
|
oldExchange := oauthExchange
|
|
oldSave := oauthSaveToken
|
|
oldRefresh := oauthRefreshToken
|
|
oldSaveSecret := oauthSaveClientSecret
|
|
oldSaveLocked := oauthSaveTokenLocked
|
|
t.Cleanup(func() {
|
|
oauthLoadToken = oldLoad
|
|
oauthLoadTokenLocked = oldLoadLocked
|
|
oauthAcquireLock = oldAcquire
|
|
oauthMarkProfile = oldMark
|
|
oauthFetchClientID = oldFetch
|
|
oauthExchange = oldExchange
|
|
oauthSaveToken = oldSave
|
|
oauthRefreshToken = oldRefresh
|
|
oauthSaveClientSecret = oldSaveSecret
|
|
oauthSaveTokenLocked = oldSaveLocked
|
|
SetClientID("")
|
|
SetClientSecret("")
|
|
resetClientIDFromMCP()
|
|
})
|
|
fail := errors.New("fail")
|
|
p := &OAuthProvider{configDir: t.TempDir(), logger: slog.Default(), Output: io.Discard}
|
|
oauthLoadTokenLocked = func(configDir, _ string) (*TokenData, error) {
|
|
return oauthLoadToken(configDir)
|
|
}
|
|
|
|
valid := &TokenData{AccessToken: "valid", ExpiresAt: time.Now().Add(time.Hour)}
|
|
oauthLoadToken = func(string) (*TokenData, error) { return valid, nil }
|
|
if got, err := p.Login(context.Background(), false); err != nil || got != valid {
|
|
t.Fatalf("silent valid login = %#v %v", got, err)
|
|
}
|
|
expired := &TokenData{AccessToken: "old", ExpiresAt: time.Now().Add(-time.Hour), RefreshToken: "refresh", RefreshExpAt: time.Now().Add(time.Hour)}
|
|
oauthLoadToken = func(string) (*TokenData, error) { return expired, nil }
|
|
oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
|
|
refreshed := &TokenData{AccessToken: "new", ExpiresAt: time.Now().Add(time.Hour)}
|
|
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return refreshed, nil }
|
|
if got, err := p.Login(context.Background(), false); err != nil || got != refreshed {
|
|
t.Fatalf("silent refresh login = %#v %v", got, err)
|
|
}
|
|
oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return nil, fail }
|
|
oauthFetchClientID = func(context.Context) (string, error) { return "", fail }
|
|
if _, err := p.Login(context.Background(), false); !errors.Is(err, fail) {
|
|
t.Fatalf("refresh fallback fetch error = %v", err)
|
|
}
|
|
|
|
oauthLoadToken = func(string) (*TokenData, error) { return nil, fail }
|
|
if _, err := p.GetAccessToken(context.Background()); err == nil {
|
|
t.Fatal("missing access token succeeded")
|
|
}
|
|
oauthLoadToken = func(string) (*TokenData, error) { return valid, nil }
|
|
if got, err := p.GetAccessToken(context.Background()); err != nil || got != "valid" {
|
|
t.Fatalf("valid access token = %q %v", got, err)
|
|
}
|
|
oauthLoadToken = func(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 refreshed, nil }
|
|
if got, err := p.GetAccessToken(context.Background()); err != nil || got != "new" {
|
|
t.Fatalf("refreshed access token = %q %v", got, err)
|
|
}
|
|
marked := false
|
|
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, fail }
|
|
oauthMarkProfile = func(string, string, string) error { marked = true; return fail }
|
|
if _, err := p.GetAccessToken(context.Background()); err == nil || !marked {
|
|
t.Fatalf("failed refresh = %v marked=%v", err, marked)
|
|
}
|
|
marked = false
|
|
noRefresh := &TokenData{AccessToken: "old", ExpiresAt: time.Now().Add(-time.Hour), CorpID: "corp"}
|
|
oauthLoadToken = func(string) (*TokenData, error) { return noRefresh, nil }
|
|
if _, err := p.GetAccessToken(context.Background()); err == nil || !marked {
|
|
t.Fatalf("expired access token = %v marked=%v", err, marked)
|
|
}
|
|
|
|
oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return nil, fail }
|
|
if _, err := p.lockedRefresh(context.Background()); !errors.Is(err, fail) {
|
|
t.Fatalf("locked refresh acquire error = %v", err)
|
|
}
|
|
oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
|
|
oauthLoadToken = func(string) (*TokenData, error) { return nil, fail }
|
|
if _, err := p.lockedRefresh(context.Background()); !errors.Is(err, fail) {
|
|
t.Fatalf("locked refresh load error = %v", err)
|
|
}
|
|
for _, waited := range []bool{false, true} {
|
|
oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{Waited: waited}, nil }
|
|
oauthLoadToken = func(string) (*TokenData, error) { return valid, nil }
|
|
if got, err := p.lockedRefresh(context.Background()); err != nil || got != valid {
|
|
t.Fatalf("double-check valid token waited=%v: %#v %v", waited, got, err)
|
|
}
|
|
}
|
|
oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
|
|
oauthLoadToken = func(string) (*TokenData, error) { return noRefresh, nil }
|
|
if _, err := p.lockedRefresh(context.Background()); err == nil {
|
|
t.Fatal("expired refresh token succeeded")
|
|
}
|
|
oauthLoadToken = func(string) (*TokenData, error) { return expired, nil }
|
|
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return refreshed, nil }
|
|
if got, err := p.lockedRefresh(context.Background()); err != nil || got != refreshed {
|
|
t.Fatalf("locked token refresh = %#v %v", got, err)
|
|
}
|
|
|
|
oauthExchange = func(*OAuthProvider, context.Context, string) (*TokenData, error) { return nil, fail }
|
|
if _, err := p.ExchangeAuthCode(context.Background(), "code", "user"); !errors.Is(err, fail) {
|
|
t.Fatalf("external exchange error = %v", err)
|
|
}
|
|
oauthExchange = func(*OAuthProvider, context.Context, string) (*TokenData, error) { return &TokenData{}, nil }
|
|
oauthSaveToken = func(string, *TokenData) error { return fail }
|
|
if _, err := p.ExchangeAuthCode(context.Background(), "code", "user"); !errors.Is(err, fail) {
|
|
t.Fatalf("external exchange save error = %v", err)
|
|
}
|
|
|
|
oauthSaveClientSecret = func(string, string) error { return fail }
|
|
SetClientID("direct")
|
|
SetClientSecret("secret")
|
|
p.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"accessToken":"access","refreshToken":"refresh"}`)), Header: make(http.Header)}, nil
|
|
})}
|
|
var warnings bytes.Buffer
|
|
p.Output = &warnings
|
|
if _, err := p.exchangeCode(context.Background(), "code"); err != nil || !strings.Contains(warnings.String(), "Warning") {
|
|
t.Fatalf("direct exchange secret warning = %v %q", err, warnings.String())
|
|
}
|
|
oauthSaveTokenLocked = func(string, *TokenData) error { return fail }
|
|
if _, err := p.refreshWithRefreshToken(context.Background(), &TokenData{ClientID: "direct", RefreshToken: "refresh"}); !errors.Is(err, fail) {
|
|
t.Fatalf("direct refresh save error = %v", err)
|
|
}
|
|
if _, err := oldRefresh(p, context.Background(), &TokenData{ClientID: "direct", RefreshToken: "refresh"}); !errors.Is(err, fail) {
|
|
t.Fatalf("default refresh hook = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
|
|
oldCheck := oauthCheckStatus
|
|
oldAdmins := oauthGetAdmins
|
|
oldApply := oauthSendApply
|
|
oldSave := oauthSaveToken
|
|
oldPoll := oauthPollInterval
|
|
oldApproval := oauthApprovalTimeout
|
|
oldLogin := oauthLoginTimeout
|
|
oldPause := oauthSuccessPause
|
|
oldSleep := oauthSleep
|
|
t.Cleanup(func() {
|
|
oauthCheckStatus = oldCheck
|
|
oauthGetAdmins = oldAdmins
|
|
oauthSendApply = oldApply
|
|
oauthSaveToken = oldSave
|
|
oauthPollInterval = oldPoll
|
|
oauthApprovalTimeout = oldApproval
|
|
oauthLoginTimeout = oldLogin
|
|
oauthSuccessPause = oldPause
|
|
oauthSleep = oldSleep
|
|
})
|
|
oauthApprovalTimeout = time.Second
|
|
oauthLoginTimeout = time.Second
|
|
oauthSuccessPause = 0
|
|
oauthSleep = func(time.Duration) {}
|
|
|
|
finishExchange := func(t *testing.T, f *oauthLoginFixture, done <-chan oauthLoginResult, code string) string {
|
|
t.Helper()
|
|
bodyCh := make(chan oauthHTTPResult, 1)
|
|
go func() {
|
|
bodyCh <- getHTTPBody(f.callbackBase + CallbackPath + "?code=" + url.QueryEscape(code))
|
|
}()
|
|
waitOAuthSignal(t, f.exchangeEntered, done, "token exchange")
|
|
closeOAuthRelease(f.exchangeRelease)
|
|
select {
|
|
case result := <-bodyCh:
|
|
if result.err != nil {
|
|
t.Fatalf("OAuth callback failed: %v", result.err)
|
|
}
|
|
return result.body
|
|
case <-time.After(oauthTestWaitTimeout):
|
|
t.Fatal("timed out waiting for OAuth callback")
|
|
return ""
|
|
}
|
|
}
|
|
|
|
t.Run("switch organization and cached disabled pages", func(t *testing.T) {
|
|
oauthPollInterval = 200 * time.Millisecond
|
|
var checks atomic.Int32
|
|
oauthCheckStatus = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) {
|
|
if checks.Add(1) == 1 {
|
|
return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}, nil
|
|
}
|
|
return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil
|
|
}
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
|
done := startOAuthLogin(t, context.Background(), f)
|
|
if body := finishExchange(t, f, done, "first"); !strings.Contains(body, "<html") {
|
|
t.Fatalf("disabled callback body = %q", body)
|
|
}
|
|
if _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=first"); !strings.Contains(body, "<html") {
|
|
t.Fatalf("cached disabled callback = %q", body)
|
|
}
|
|
if _, body := httpGetBody(t, f.callbackBase+CallbackPath); !strings.Contains(body, "<html") {
|
|
t.Fatalf("cached disabled no-code callback = %q", body)
|
|
}
|
|
if _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=second"); !strings.Contains(body, "<html") {
|
|
t.Fatalf("switched callback = %q", body)
|
|
}
|
|
result := awaitOAuthLogin(t, done)
|
|
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
|
|
t.Fatalf("switched login = %#v %v", result.token, result.err)
|
|
}
|
|
})
|
|
|
|
t.Run("callback and API errors", func(t *testing.T) {
|
|
oauthPollInterval = time.Hour
|
|
fail := errors.New("hook failure")
|
|
oauthCheckStatus = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) { return nil, fail }
|
|
oauthGetAdmins = func(context.Context, string) (*SuperAdminResponse, error) { return nil, fail }
|
|
oauthSendApply = func(context.Context, string, string) (*SendApplyResponse, error) { return nil, fail }
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
|
done := startOAuthLogin(t, ctx, f)
|
|
finishExchange(t, f, done, "errors")
|
|
for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin", "/api/cliAuthEnabled"} {
|
|
if _, body := httpGetBody(t, f.callbackBase+path); !strings.Contains(body, "hook failure") {
|
|
t.Fatalf("API error %s = %q", path, body)
|
|
}
|
|
}
|
|
cancel()
|
|
if result := awaitOAuthLogin(t, done); !errors.Is(result.err, context.Canceled) {
|
|
t.Fatalf("canceled error login = %v", result.err)
|
|
}
|
|
})
|
|
|
|
t.Run("apply sent polling", func(t *testing.T) {
|
|
oauthPollInterval = 5 * time.Millisecond
|
|
oauthCheckStatus = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) {
|
|
return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}, nil
|
|
}
|
|
oauthSendApply = func(context.Context, string, string) (*SendApplyResponse, error) {
|
|
return &SendApplyResponse{Success: true, Result: true}, nil
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
|
var output bytes.Buffer
|
|
f.provider.Output = &output
|
|
done := startOAuthLogin(t, ctx, f)
|
|
finishExchange(t, f, done, "apply")
|
|
if _, body := httpGetBody(t, f.callbackBase+"/api/sendApply?adminStaffId=admin"); !strings.Contains(body, "true") {
|
|
t.Fatalf("apply response = %q", body)
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
cancel()
|
|
if result := awaitOAuthLogin(t, done); !errors.Is(result.err, context.Canceled) {
|
|
t.Fatalf("canceled apply login = %v", result.err)
|
|
}
|
|
if !strings.Contains(output.String(), "Waiting for admin approval") && !strings.Contains(output.String(), "等待管理员审批中") {
|
|
t.Fatalf("apply polling output = %q", output.String())
|
|
}
|
|
})
|
|
|
|
t.Run("enterprise denial default", func(t *testing.T) {
|
|
oauthPollInterval = time.Hour
|
|
oauthCheckStatus = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) {
|
|
return &CLIAuthStatus{Success: false, ErrorCode: "ENTERPRISE_NOT_AUTHORIZED"}, nil
|
|
}
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
|
done := startOAuthLogin(t, context.Background(), f)
|
|
finishExchange(t, f, done, "enterprise")
|
|
if result := awaitOAuthLogin(t, done); result.err == nil || !strings.Contains(result.err.Error(), "企业安全认证") {
|
|
t.Fatalf("enterprise denial = %v", result.err)
|
|
}
|
|
})
|
|
|
|
t.Run("initial context cancellation", func(t *testing.T) {
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
|
SetClientID("direct")
|
|
SetClientSecret("secret")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := f.provider.Login(ctx, true); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("initial cancellation = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("save failure", func(t *testing.T) {
|
|
oauthCheckStatus = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) {
|
|
return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil
|
|
}
|
|
fail := errors.New("save failure")
|
|
oauthSaveToken = func(string, *TokenData) error { return fail }
|
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
|
done := startOAuthLogin(t, context.Background(), f)
|
|
finishExchange(t, f, done, "save")
|
|
if result := awaitOAuthLogin(t, done); !errors.Is(result.err, fail) {
|
|
t.Fatalf("save failure login = %v", result.err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageOAuthHelperRemainingEdges(t *testing.T) {
|
|
isolateOAuthPersistence(t)
|
|
oldClient := oauthHTTPClient
|
|
oldRequest := oauthNewRequest
|
|
oldRetry := oauthRetryAfter
|
|
oldSaveLocked := oauthSaveTokenLocked
|
|
t.Cleanup(func() {
|
|
oauthHTTPClient = oldClient
|
|
oauthNewRequest = oldRequest
|
|
oauthRetryAfter = oldRetry
|
|
oauthSaveTokenLocked = oldSaveLocked
|
|
SetClientID("")
|
|
SetClientSecret("")
|
|
resetClientIDFromMCP()
|
|
})
|
|
fail := errors.New("fail")
|
|
networkClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, fail })}
|
|
responseClient := func(body string) *http.Client {
|
|
return &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
|
})}
|
|
}
|
|
p := &OAuthProvider{configDir: t.TempDir(), Output: io.Discard, httpClient: networkClient}
|
|
SetClientID("direct")
|
|
SetClientSecret("secret")
|
|
if _, err := p.exchangeCode(context.Background(), "code"); !errors.Is(err, fail) {
|
|
t.Fatalf("direct exchange request error = %v", err)
|
|
}
|
|
oauthHTTPClient = networkClient
|
|
if _, err := ExchangeCodeForToken(context.Background(), p.configDir, "code"); !errors.Is(err, fail) {
|
|
t.Fatalf("exchange wrapper request error = %v", err)
|
|
}
|
|
p.httpClient = responseClient("{")
|
|
if _, err := p.exchangeCode(context.Background(), "code"); err == nil {
|
|
t.Fatal("malformed direct exchange succeeded")
|
|
}
|
|
oauthHTTPClient = responseClient(`{"accessToken":"wrapper","refreshToken":"refresh"}`)
|
|
if got, err := ExchangeCodeForToken(context.Background(), p.configDir, "code"); err != nil || got.AccessToken != "wrapper" {
|
|
t.Fatalf("exchange wrapper = %#v %v", got, err)
|
|
}
|
|
|
|
SetClientIDFromMCP("mcp-client")
|
|
p.httpClient = networkClient
|
|
if _, err := p.exchangeCodeViaMCP(context.Background(), "code"); !errors.Is(err, fail) {
|
|
t.Fatalf("MCP exchange request error = %v", err)
|
|
}
|
|
p.httpClient = responseClient("{")
|
|
if _, err := p.exchangeCodeViaMCP(context.Background(), "code"); err == nil {
|
|
t.Fatal("malformed MCP exchange succeeded")
|
|
}
|
|
resetClientIDFromMCP()
|
|
SetClientID("direct")
|
|
SetClientSecret("secret")
|
|
legacy := &TokenData{RefreshToken: "refresh"}
|
|
p.httpClient = networkClient
|
|
if _, err := p.refreshWithRefreshToken(context.Background(), legacy); !errors.Is(err, fail) {
|
|
t.Fatalf("direct refresh request error = %v", err)
|
|
}
|
|
p.httpClient = responseClient("{")
|
|
if _, err := p.refreshWithRefreshToken(context.Background(), legacy); err == nil {
|
|
t.Fatal("malformed direct refresh succeeded")
|
|
}
|
|
|
|
SetClientIDFromMCP("mcp-client")
|
|
mcpData := &TokenData{ClientID: "mcp-client", Source: "mcp", RefreshToken: "refresh"}
|
|
p.httpClient = networkClient
|
|
if _, err := p.refreshWithRefreshToken(context.Background(), mcpData); !errors.Is(err, fail) {
|
|
t.Fatalf("MCP refresh dispatch error = %v", err)
|
|
}
|
|
p.httpClient = responseClient("{")
|
|
if _, err := p.refreshViaMCP(context.Background(), mcpData); err == nil {
|
|
t.Fatal("malformed MCP refresh succeeded")
|
|
}
|
|
p.httpClient = responseClient(`{"accessToken":"new","refreshToken":"refresh","persistentCode":"pc"}`)
|
|
oauthSaveTokenLocked = func(string, *TokenData) error { return fail }
|
|
if _, err := p.refreshViaMCP(context.Background(), mcpData); !errors.Is(err, fail) {
|
|
t.Fatalf("MCP refresh save error = %v", err)
|
|
}
|
|
oauthSaveTokenLocked = oldSaveLocked
|
|
|
|
if parsed, err := p.parseTokenResponse([]byte(`{"accessToken":"a","persistentCode":"pc"}`)); err != nil || parsed.PersistentCode != "pc" {
|
|
t.Fatalf("direct persistent code = %#v %v", parsed, err)
|
|
}
|
|
if _, err := p.parseMCPTokenResponse([]byte(`{"errorCode":"bad","errorMsg":"failed"}`)); err == nil {
|
|
t.Fatal("MCP business error parsed")
|
|
}
|
|
if parsed, err := p.parseMCPTokenResponse([]byte(`{"accessToken":"a","persistentCode":"pc"}`)); err != nil || parsed.PersistentCode != "pc" {
|
|
t.Fatalf("MCP persistent code = %#v %v", parsed, err)
|
|
}
|
|
if !strings.Contains(renderEnterpriseDeniedHTML(""), defaultEnterpriseDeniedMsg) {
|
|
t.Fatal("default enterprise denial message missing")
|
|
}
|
|
|
|
oauthNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
|
|
p.httpClient = responseClient("{}")
|
|
if _, err := p.doCheckCLIAuthEnabled(context.Background(), "token"); !errors.Is(err, fail) {
|
|
t.Fatalf("CLI status request error = %v", err)
|
|
}
|
|
if _, err := doGetSuperAdmins(context.Background(), "token"); !errors.Is(err, fail) {
|
|
t.Fatalf("admins request error = %v", err)
|
|
}
|
|
if _, err := doSendCliAuthApply(context.Background(), "token", "admin"); !errors.Is(err, fail) {
|
|
t.Fatalf("apply request error = %v", err)
|
|
}
|
|
if _, err := doFetchClientIDFromMCP(context.Background()); !errors.Is(err, fail) {
|
|
t.Fatalf("client-ID request error = %v", err)
|
|
}
|
|
oauthNewRequest = oldRequest
|
|
|
|
for name, client := range map[string]*http.Client{
|
|
"network": networkClient,
|
|
"read": {Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(errorReader{}), Header: make(http.Header)}, nil
|
|
})},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
p.httpClient = client
|
|
oauthHTTPClient = client
|
|
if _, err := p.doCheckCLIAuthEnabled(context.Background(), "token"); err == nil {
|
|
t.Fatal("CLI status transport failure succeeded")
|
|
}
|
|
if _, err := doGetSuperAdmins(context.Background(), "token"); err == nil {
|
|
t.Fatal("admins transport failure succeeded")
|
|
}
|
|
if _, err := doSendCliAuthApply(context.Background(), "token", "admin"); err == nil {
|
|
t.Fatal("apply transport failure succeeded")
|
|
}
|
|
if _, err := doFetchClientIDFromMCP(context.Background()); err == nil {
|
|
t.Fatal("client-ID transport failure succeeded")
|
|
}
|
|
})
|
|
}
|
|
|
|
for name, call := range map[string]func() error{
|
|
"status": func() error { _, err := p.doCheckCLIAuthEnabled(context.Background(), "token"); return err },
|
|
"admins": func() error { _, err := doGetSuperAdmins(context.Background(), "token"); return err },
|
|
"apply": func() error { _, err := doSendCliAuthApply(context.Background(), "token", "admin"); return err },
|
|
"client": func() error { _, err := doFetchClientIDFromMCP(context.Background()); return err },
|
|
} {
|
|
t.Run("malformed "+name, func(t *testing.T) {
|
|
client := responseClient("{")
|
|
p.httpClient = client
|
|
oauthHTTPClient = client
|
|
if err := call(); err == nil {
|
|
t.Fatal("malformed MCP response succeeded")
|
|
}
|
|
})
|
|
}
|
|
oauthHTTPClient = responseClient(`{"success":false,"errorCode":"bad","errorMsg":"failed"}`)
|
|
if _, err := doFetchClientIDFromMCP(context.Background()); err == nil {
|
|
t.Fatal("failed client-ID response succeeded")
|
|
}
|
|
|
|
oauthRetryAfter = func(time.Duration) <-chan time.Time { return make(chan time.Time) }
|
|
p.httpClient = networkClient
|
|
oauthHTTPClient = networkClient
|
|
canceled, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := p.CheckCLIAuthEnabled(canceled, "token"); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled CLI status retry = %v", err)
|
|
}
|
|
if _, err := GetSuperAdmins(canceled, "token"); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled admins retry = %v", err)
|
|
}
|
|
if _, err := SendCliAuthApply(canceled, "token", "admin"); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled apply retry = %v", err)
|
|
}
|
|
if _, err := FetchClientIDFromMCP(canceled); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled client-ID retry = %v", err)
|
|
}
|
|
}
|