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, " 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, "