From ec83d8ff53df366f5388baeb2c09b50b7398c0bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=99=E8=BE=89?= Date: Thu, 13 Aug 2026 18:10:23 +0800 Subject: [PATCH] fix(auth): harden international login routing --- internal/app/auth_command.go | 23 ++- .../app/auth_command_full_coverage_test.go | 41 +++++- internal/app/auth_command_test.go | 135 ++++++++++++++++-- internal/app/coverage_edges_test.go | 67 ++++++++- internal/app/root_help_test.go | 6 +- internal/app/runner_full_coverage_test.go | 30 ++++ internal/auth/auth_extra_test.go | 39 ++++- internal/auth/cli_auth_check_test.go | 48 ++++++- internal/auth/device_flow_test.go | 2 +- internal/auth/endpoints.go | 3 + internal/errors/errors_test.go | 2 +- internal/transport/client_extra_test.go | 8 +- pkg/config/constants_test.go | 4 +- 13 files changed, 373 insertions(+), 35 deletions(-) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 07e58d9b..d4e0f7c1 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -173,6 +173,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { AccessToken: cfg.Token, ExpiresAt: time.Now().Add(config.ManualTokenExpiry), } + if cfg.International { + tokenData.LoginRegion = string(authpkg.LoginRegionInternational) + } if err := authSaveTokenData(configDir, tokenData); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err)) } @@ -1384,13 +1387,10 @@ func authLoginMCPBaseURLForConfig(cfg authLoginConfig, preOverrides authLoginEnd } func persistAuthLoginMCPBaseURL(configDir, mcpBaseURL string, persist bool) error { - if persist { - return authpkg.NewManager(configDir, nil).SaveMCPURL(mcpBaseURL) + if !persist { + return nil } - if err := os.Remove(filepath.Join(configDir, "mcp_url")); err != nil && !os.IsNotExist(err) { - return err - } - return nil + return authpkg.NewManager(configDir, nil).SaveMCPURL(mcpBaseURL) } func normalizeAuthLoginBaseURL(raw, flagName string) (*url.URL, string, error) { @@ -1411,12 +1411,23 @@ func normalizeAuthLoginBaseURL(raw, flagName string) (*url.URL, string, error) { if parsed.Hostname() == "" { return nil, "", apperrors.NewValidation(flagName + " must include a host") } + if parsed.Scheme == "http" && !isAuthLoginLoopbackHost(parsed.Hostname()) { + return nil, "", apperrors.NewValidation(flagName + " must use HTTPS, except for a loopback HTTP test endpoint") + } parsed.RawQuery = "" parsed.Fragment = "" parsed.Path = strings.TrimRight(parsed.Path, "/") return parsed, strings.TrimRight(parsed.String(), "/"), nil } +func isAuthLoginLoopbackHost(host string) bool { + if strings.EqualFold(strings.TrimSpace(host), "localhost") { + return true + } + ip := net.ParseIP(strings.TrimSpace(host)) + return ip != nil && ip.IsLoopback() +} + func authLoginURLWithHost(parsed *url.URL, host string) string { copyURL := *parsed if port := parsed.Port(); port != "" { diff --git a/internal/app/auth_command_full_coverage_test.go b/internal/app/auth_command_full_coverage_test.go index b572a375..4ba58ac3 100644 --- a/internal/app/auth_command_full_coverage_test.go +++ b/internal/app/auth_command_full_coverage_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "testing" "time" @@ -214,7 +215,8 @@ func TestCrossPlatformCoverageAuthCoverageFormsParentAndTargets(t *testing.T) { } func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { - t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) oldSave := authSaveTokenData oldDevice := authDeviceLogin oldOAuth := authOAuthLogin @@ -255,6 +257,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) { t.Fatalf("json token login = %q, %v", out, err) } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://example.com"}); err == nil { + t.Fatal("invalid pre-release host should fail") + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "mcp-url": "http://remote.example.com"}); err == nil { + t.Fatal("remote plaintext MCP URL should fail") + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://pre-login.dingtalk.io"}); err != nil { + t.Fatalf("pre-release token login = %v", err) + } authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) { return nil, errors.New("device") @@ -271,6 +282,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil { t.Fatal(err) } + authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, _ context.Context) (*authpkg.TokenData, error) { + if provider.LoginRegion != authpkg.LoginRegionInternational { + t.Errorf("device login region = %q, want international", provider.LoginRegion) + } + return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "intl": "true"}); err != nil { + t.Fatalf("international device login = %v", err) + } authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) { return nil, errors.New("oauth") @@ -291,6 +311,25 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") { t.Fatalf("oauth success = %q, %v", out, err) } + authOAuthLogin = func(provider *authpkg.OAuthProvider, _ context.Context, _ bool) (*authpkg.TokenData, error) { + if provider.LoginRegion != authpkg.LoginRegionInternational { + t.Errorf("OAuth login region = %q, want international", provider.LoginRegion) + } + return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"intl": "true"}); err != nil { + t.Fatalf("international OAuth login = %v", err) + } + + blockedConfigDir := t.TempDir() + if err := os.Mkdir(filepath.Join(blockedConfigDir, "mcp_url"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("DWS_CONFIG_DIR", blockedConfigDir) + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "intl": "true"}); err == nil || !strings.Contains(err.Error(), "failed to persist MCP URL") { + t.Fatalf("MCP URL persist failure = %v", err) + } + t.Setenv("DWS_CONFIG_DIR", configDir) authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error { return errors.New("recommend") diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index a1ea7144..e4707218 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -1065,7 +1065,7 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) { } } -func TestResolveAuthLoginConfigReadsInternationalAliases(t *testing.T) { +func TestCrossPlatformCoverageResolveAuthLoginConfigReadsInternationalAliases(t *testing.T) { for _, flag := range []string{"intl", "international"} { t.Run(flag, func(t *testing.T) { root := &cobra.Command{Use: "dws"} @@ -1094,9 +1094,61 @@ func TestResolveAuthLoginConfigReadsInternationalAliases(t *testing.T) { } }) } + + for _, tc := range []struct { + name string + setup func(*cobra.Command) + }{ + { + name: "missing intl", + setup: func(cmd *cobra.Command) { + cmd.Flags().String("token", "", "") + cmd.Flags().Bool("device", false, "") + }, + }, + { + name: "missing international", + setup: func(cmd *cobra.Command) { + cmd.Flags().String("token", "", "") + cmd.Flags().Bool("device", false, "") + cmd.Flags().Bool("intl", false, "") + }, + }, + { + name: "missing pre url", + setup: func(cmd *cobra.Command) { + cmd.Flags().String("token", "", "") + cmd.Flags().Bool("device", false, "") + cmd.Flags().Bool("intl", false, "") + cmd.Flags().Bool("international", false, "") + cmd.Flags().Bool("force", false, "") + cmd.Flags().Bool("recommend", false, "") + }, + }, + { + name: "missing mcp url", + setup: func(cmd *cobra.Command) { + cmd.Flags().String("token", "", "") + cmd.Flags().Bool("device", false, "") + cmd.Flags().Bool("intl", false, "") + cmd.Flags().Bool("international", false, "") + cmd.Flags().Bool("force", false, "") + cmd.Flags().Bool("recommend", false, "") + cmd.Flags().String("pre-url", "", "") + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "login"} + tc.setup(cmd) + if _, err := resolveAuthLoginConfig(cmd); err == nil { + t.Fatal("resolveAuthLoginConfig succeeded with an incomplete flag set") + } + }) + } } -func TestResolveAuthLoginConfigReadsMCPURL(t *testing.T) { +func TestCrossPlatformCoverageResolveAuthLoginConfigReadsMCPURL(t *testing.T) { root := &cobra.Command{Use: "dws"} root.PersistentFlags().Bool("yes", false, "") login := &cobra.Command{Use: "login"} @@ -1123,7 +1175,7 @@ func TestResolveAuthLoginConfigReadsMCPURL(t *testing.T) { } } -func TestResolveAuthLoginConfigReadsPreURL(t *testing.T) { +func TestCrossPlatformCoverageResolveAuthLoginConfigReadsPreURL(t *testing.T) { root := &cobra.Command{Use: "dws"} root.PersistentFlags().Bool("yes", false, "") login := &cobra.Command{Use: "login"} @@ -1150,7 +1202,7 @@ func TestResolveAuthLoginConfigReadsPreURL(t *testing.T) { } } -func TestAuthLoginEndpointOverridesForPreURL(t *testing.T) { +func TestCrossPlatformCoverageAuthLoginEndpointOverridesForPreURL(t *testing.T) { tests := []struct { name string raw string @@ -1169,6 +1221,12 @@ func TestAuthLoginEndpointOverridesForPreURL(t *testing.T) { wantLogin: "https://pre-login.dingtalk.io", wantMCP: "https://pre-mcp.dingtalk.io", }, + { + name: "pre login with port", + raw: "https://pre-login.dingtalk.io:8443/path/", + wantLogin: "https://pre-login.dingtalk.io:8443/path", + wantMCP: "https://pre-mcp.dingtalk.io:8443/path", + }, } for _, tc := range tests { @@ -1182,9 +1240,15 @@ func TestAuthLoginEndpointOverridesForPreURL(t *testing.T) { } }) } + + for _, raw := range []string{"https://example.com", "http://pre-login.example.com"} { + if _, err := authLoginEndpointOverridesForPreURL(raw); err == nil { + t.Fatalf("authLoginEndpointOverridesForPreURL(%q) succeeded", raw) + } + } } -func TestAuthLoginMCPBaseURLForConfig(t *testing.T) { +func TestCrossPlatformCoverageAuthLoginMCPBaseURLForConfig(t *testing.T) { tests := []struct { name string cfg authLoginConfig @@ -1240,9 +1304,18 @@ func TestAuthLoginMCPBaseURLForConfig(t *testing.T) { } }) } + + for _, cfg := range []authLoginConfig{ + {MCPURL: "http://remote.example.com"}, + {PreURL: "https://example.com"}, + } { + if _, _, err := authLoginMCPBaseURLForConfig(cfg, authLoginEndpointOverrides{}); err == nil { + t.Fatalf("authLoginMCPBaseURLForConfig(%#v) succeeded", cfg) + } + } } -func TestPersistAuthLoginMCPBaseURL(t *testing.T) { +func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURL(t *testing.T) { t.Run("persists selected io mcp url", func(t *testing.T) { configDir := t.TempDir() if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, true); err != nil { @@ -1257,21 +1330,63 @@ func TestPersistAuthLoginMCPBaseURL(t *testing.T) { } }) - t.Run("center login clears previous persisted override", func(t *testing.T) { + t.Run("center login preserves previous persisted override", func(t *testing.T) { configDir := t.TempDir() mcpURLPath := filepath.Join(configDir, "mcp_url") - if err := os.WriteFile(mcpURLPath, []byte("https://mcp.dingtalk.io"), 0o600); err != nil { + const customURL = "https://custom-mcp.example.com" + if err := os.WriteFile(mcpURLPath, []byte(customURL), 0o600); err != nil { t.Fatalf("WriteFile(mcp_url) error = %v", err) } if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, false); err != nil { t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err) } - if _, err := os.Stat(mcpURLPath); !os.IsNotExist(err) { - t.Fatalf("mcp_url still exists after center login, stat err = %v", err) + data, err := os.ReadFile(mcpURLPath) + if err != nil { + t.Fatalf("ReadFile(mcp_url) error = %v", err) + } + if string(data) != customURL { + t.Fatalf("mcp_url = %q, want preserved override %q", string(data), customURL) } }) } +func TestCrossPlatformCoverageNormalizeAuthLoginBaseURLTransportSecurity(t *testing.T) { + tests := []struct { + name string + raw string + wantURL string + wantErr string + }{ + {name: "https remote", raw: "https://pre-mcp.example.com/path/?secret=drop#fragment", wantURL: "https://pre-mcp.example.com/path"}, + {name: "http localhost", raw: "http://localhost:8080/", wantURL: "http://localhost:8080"}, + {name: "http IPv4 loopback", raw: "http://127.0.0.1:8080", wantURL: "http://127.0.0.1:8080"}, + {name: "http IPv6 loopback", raw: "http://[::1]:8080", wantURL: "http://[::1]:8080"}, + {name: "http remote", raw: "http://pre-mcp.example.com", wantErr: "must use HTTPS"}, + {name: "empty", raw: " ", wantErr: "cannot be empty"}, + {name: "invalid URL", raw: "https://%", wantErr: "invalid --mcp-url"}, + {name: "invalid scheme", raw: "ftp://pre-mcp.example.com", wantErr: "must use http or https"}, + {name: "missing host", raw: "https:///path", wantErr: "must include a host"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, got, err := normalizeAuthLoginBaseURL(tc.raw, "--mcp-url") + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("normalizeAuthLoginBaseURL error = %v, want containing %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("normalizeAuthLoginBaseURL error = %v", err) + } + if got != tc.wantURL { + t.Fatalf("normalized URL = %q, want %q", got, tc.wantURL) + } + }) + } +} + func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) { if !authLoginForcesAuthorization(authLoginConfig{}) { t.Fatal("auth login should force authorization by default so each login can add an organization profile") diff --git a/internal/app/coverage_edges_test.go b/internal/app/coverage_edges_test.go index db7e8f86..9d634365 100644 --- a/internal/app/coverage_edges_test.go +++ b/internal/app/coverage_edges_test.go @@ -326,6 +326,29 @@ func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) { func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) { oldEdition := edition.Get() t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) }) + for _, tc := range []struct { + raw string + region authpkg.LoginRegion + want string + }{ + {raw: "%", want: "%"}, + {raw: "https://dingtalk.io/path", want: "https://dingtalk.com/path"}, + {raw: "https://mcp.dingtalk.com:8443/path", region: authpkg.LoginRegionInternational, want: "https://mcp.dingtalk.io:8443/path"}, + } { + if got := mcpBaseURLForLoginRegion(tc.raw, tc.region); got != tc.want { + t.Fatalf("mcpBaseURLForLoginRegion(%q, %q) = %q, want %q", tc.raw, tc.region, got, tc.want) + } + } + if hasDirectRuntimeEndpointOverride("") { + t.Fatal("blank product unexpectedly has an endpoint override") + } + t.Setenv("DINGTALK_COVERAGE_PRODUCT_MCP_URL", "https://override.test") + if !hasDirectRuntimeEndpointOverride("coverage-product") { + t.Fatal("configured product endpoint override was not detected") + } + if got := activeDingTalkGatewayEndpointWithBase("https://mcp-gw.dingtalk.com/server/contact", "%"); got != "https://mcp-gw.dingtalk.com/server/contact" { + t.Fatalf("invalid gateway base rewrote endpoint to %q", got) + } server := mcptypes.ServerDescriptor{ Endpoint: "https://one.test", CLI: mcptypes.CLIOverlay{ @@ -913,12 +936,21 @@ func TestCrossPlatformCoverageAuthCommandPureCoverage(t *testing.T) { } func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) oldInteractive := authLoginInteractiveTerminal authLoginInteractiveTerminal = func() bool { return false } t.Cleanup(func() { authLoginInteractiveTerminal = oldInteractive; authpkg.SetRuntimeProfile("") }) - for _, format := range []string{"table", "json"} { - t.Run(format, func(t *testing.T) { - t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + for _, tc := range []struct { + format string + international bool + }{ + {format: "table"}, + {format: "json", international: true}, + } { + t.Run(tc.format, func(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) root := &cobra.Command{Use: "dws"} root.PersistentFlags().String("format", "table", "") root.PersistentFlags().Bool("yes", false, "") @@ -929,13 +961,40 @@ func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) { root.SetOut(&output) root.SetErr(io.Discard) args := []string{"login", "--token", "manual-token", "--yes"} - if format == "json" { + if tc.international { + args = append(args, "--intl") + } + if tc.format == "json" { args = append(args, "--format", "json") } root.SetArgs(args) if err := root.Execute(); err != nil || output.Len() == 0 { t.Fatalf("token login = %q %v", output.String(), err) } + data, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData error = %v", err) + } + wantRegion := "" + if tc.international { + wantRegion = string(authpkg.LoginRegionInternational) + } + if data.LoginRegion != wantRegion { + t.Fatalf("LoginRegion = %q, want %q", data.LoginRegion, wantRegion) + } + if tc.international { + snapshot, err := resolveAccessTokenSnapshotFromDir(context.Background(), configDir, "") + if err != nil { + t.Fatalf("resolveAccessTokenSnapshotFromDir error = %v", err) + } + gotEndpoint := activeDingTalkGatewayEndpointForLoginRegion( + "https://mcp-gw.dingtalk.com/server/contact", + snapshot.LoginRegion, + ) + if wantEndpoint := "https://mcp-gw.dingtalk.io/server/contact"; gotEndpoint != wantEndpoint { + t.Fatalf("international manual-token endpoint = %q, want %q", gotEndpoint, wantEndpoint) + } + } }) } diff --git a/internal/app/root_help_test.go b/internal/app/root_help_test.go index b5e15e9a..836287a7 100644 --- a/internal/app/root_help_test.go +++ b/internal/app/root_help_test.go @@ -474,7 +474,7 @@ func TestInjectStaticServersMergesStaticAndSupplementServers(t *testing.T) { } } -func TestStaticDingTalkEndpointsFollowConfiguredMCPBaseURL(t *testing.T) { +func TestCrossPlatformCoverageStaticDingTalkEndpointsFollowConfiguredMCPBaseURL(t *testing.T) { previous := edition.Get() defer edition.Override(previous) defer SetDynamicServers(nil) @@ -508,7 +508,7 @@ func TestStaticDingTalkEndpointsFollowConfiguredMCPBaseURL(t *testing.T) { } } -func TestDingTalkEndpointsFollowSelectedTokenRegion(t *testing.T) { +func TestCrossPlatformCoverageDingTalkEndpointsFollowSelectedTokenRegion(t *testing.T) { configDir := t.TempDir() t.Setenv("DWS_CONFIG_DIR", configDir) mcpURLPath := filepath.Join(configDir, "mcp_url") @@ -532,7 +532,7 @@ func TestDingTalkEndpointsFollowSelectedTokenRegion(t *testing.T) { } } -func TestDingTalkEndpointUsesLoginScopedMCPOverride(t *testing.T) { +func TestCrossPlatformCoverageDingTalkEndpointUsesLoginScopedMCPOverride(t *testing.T) { configDir := t.TempDir() t.Setenv("DWS_CONFIG_DIR", configDir) restore := authpkg.PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io") diff --git a/internal/app/runner_full_coverage_test.go b/internal/app/runner_full_coverage_test.go index 34e1b0ba..28de556c 100644 --- a/internal/app/runner_full_coverage_test.go +++ b/internal/app/runner_full_coverage_test.go @@ -127,12 +127,14 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) { } func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) oldEdition := edition.Get() oldPreflight := runnerPreflightDocDownload oldCall := runnerCallTool oldHandle := runnerHandlePatAuthCheck oldRetry := runnerRetryWithPatAuthRetry oldCapture := runnerCaptureRuntimeFailure + oldResolveSnapshot := runnerResolveAuthSnapshot t.Cleanup(func() { edition.Override(oldEdition) runnerPreflightDocDownload = oldPreflight @@ -140,6 +142,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) { runnerHandlePatAuthCheck = oldHandle runnerRetryWithPatAuthRetry = oldRetry runnerCaptureRuntimeFailure = oldCapture + runnerResolveAuthSnapshot = oldResolveSnapshot }) pluginAuthMu.Lock() @@ -168,6 +171,27 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) { if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented { t.Fatalf("default auth execution = %#v, %v", got, err) } + runnerResolveAuthSnapshot = func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) { + return AccessTokenSnapshot{ + AccessToken: "international-token", + LoginRegion: authpkg.LoginRegionInternational, + LoginRegionKnown: true, + }, nil + } + successfulCall := runnerCallTool + routedEndpoint := "" + runnerCallTool = func(_ *transport.Client, _ context.Context, endpoint, _ string, _ map[string]any) (transport.ToolCallResult, error) { + routedEndpoint = endpoint + return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil + } + if _, err := r.executeInvocation(context.Background(), "https://mcp-gw.dingtalk.com/server/contact", inv); err != nil { + t.Fatalf("international auth execution = %v", err) + } + if routedEndpoint != "https://mcp-gw.dingtalk.io/server/contact" { + t.Fatalf("international routed endpoint = %q", routedEndpoint) + } + runnerResolveAuthSnapshot = oldResolveSnapshot + runnerCallTool = successfulCall wantErr := errors.New("preflight") runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error { @@ -362,6 +386,12 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" { t.Fatalf("runtime explicit token = %q, %v", got, err) } + edition.Override(&edition.Hooks{TokenProvider: func(context.Context, func() (string, error)) (string, error) { + return "", errors.New("snapshot failed") + }}) + if _, err := r.resolveAuthToken(context.Background()); err == nil || !strings.Contains(err.Error(), "snapshot failed") { + t.Fatalf("resolveAuthToken error = %v", err) + } t.Setenv(envDWSChannel, "channel") edition.Override(&edition.Hooks{ diff --git a/internal/auth/auth_extra_test.go b/internal/auth/auth_extra_test.go index ccfb68a5..bc4909bd 100644 --- a/internal/auth/auth_extra_test.go +++ b/internal/auth/auth_extra_test.go @@ -424,14 +424,14 @@ func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) { } } -func TestBuildAuthURLForInternationalRegion(t *testing.T) { +func TestCrossPlatformCoverageBuildAuthURLForInternationalRegion(t *testing.T) { authURL := buildAuthURLForRegion("client-id", "http://127.0.0.1:1234/callback", "", LoginRegionInternational) if !strings.HasPrefix(authURL, InternationalAuthorizeURL+"?") { t.Fatalf("auth URL = %s, want international authorize host", authURL) } } -func TestNotEnabledHTMLUsesRegionAwareAuthorizeURL(t *testing.T) { +func TestCrossPlatformCoverageNotEnabledHTMLUsesRegionAwareAuthorizeURL(t *testing.T) { if !strings.Contains(notEnabledHTML, "status.authorizeUrl") { t.Fatal("not-enabled page must read the authorize URL from the regional login status") } @@ -440,10 +440,13 @@ func TestNotEnabledHTMLUsesRegionAwareAuthorizeURL(t *testing.T) { } } -func TestLoginRegionEndpointDefaults(t *testing.T) { +func TestCrossPlatformCoverageLoginRegionEndpointDefaults(t *testing.T) { if got := AuthorizeURLForLoginRegion(LoginRegionDefault); got != AuthorizeURL { t.Fatalf("default authorize URL = %q, want %q", got, AuthorizeURL) } + if got := DeviceBaseURLForLoginRegion(LoginRegionDefault); got != DefaultDeviceBaseURL { + t.Fatalf("default device base URL = %q, want %q", got, DefaultDeviceBaseURL) + } if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != InternationalUserAccessTokenURL { t.Fatalf("international user access token URL = %q, want %q", got, InternationalUserAccessTokenURL) } @@ -455,7 +458,30 @@ func TestLoginRegionEndpointDefaults(t *testing.T) { } } -func TestMCPBaseURLOverrideAffectsInternationalRegion(t *testing.T) { +func TestCrossPlatformCoverageOAuthProviderLoginRegionHelpers(t *testing.T) { + var nilProvider *OAuthProvider + if got := nilProvider.loginRegion(); got != LoginRegionDefault { + t.Fatalf("nil provider login region = %q", got) + } + nilProvider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)}) + nilProvider.applyLoginRegionToToken(&TokenData{}) + + provider := &OAuthProvider{} + provider.useTokenLoginRegion(nil) + provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)}) + if provider.LoginRegion != LoginRegionInternational { + t.Fatalf("provider login region = %q, want international", provider.LoginRegion) + } + provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionDefault)}) + provider.applyLoginRegionToToken(nil) + token := &TokenData{} + provider.applyLoginRegionToToken(token) + if token.LoginRegion != string(LoginRegionInternational) { + t.Fatalf("token login region = %q, want international", token.LoginRegion) + } +} + +func TestCrossPlatformCoverageMCPBaseURLOverrideAffectsInternationalRegion(t *testing.T) { restore := PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io/") defer restore() @@ -464,7 +490,7 @@ func TestMCPBaseURLOverrideAffectsInternationalRegion(t *testing.T) { } } -func TestLoginBaseURLOverrideAffectsInternationalRegion(t *testing.T) { +func TestCrossPlatformCoverageLoginBaseURLOverrideAffectsInternationalRegion(t *testing.T) { restore := PushLoginBaseURLOverride("https://pre-login.dingtalk.io/") defer restore() @@ -474,6 +500,9 @@ func TestLoginBaseURLOverrideAffectsInternationalRegion(t *testing.T) { if got := AuthorizeURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/oauth2/auth" { t.Fatalf("international authorize URL = %q, want override", got) } + if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/v1.0/oauth2/userAccessToken" { + t.Fatalf("international user access token URL = %q, want override", got) + } } func buildTokenDataFromResponse(resp tokenResponse) *TokenData { diff --git a/internal/auth/cli_auth_check_test.go b/internal/auth/cli_auth_check_test.go index 7fb10fba..802035ea 100644 --- a/internal/auth/cli_auth_check_test.go +++ b/internal/auth/cli_auth_check_test.go @@ -208,8 +208,10 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) { t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled) } -func TestCheckCLIAuthEnabled_TraceHeadersAndDebugLog(t *testing.T) { +func TestCrossPlatformCoverageCheckCLIAuthEnabledTraceHeadersAndDebugLog(t *testing.T) { t.Setenv("DINGTALK_TRACE_ID", "trace-for-cli-auth-test") + applyCLIAuthTraceHeaders(nil, "trace-for-cli-auth-test") + applyCLIAuthTraceHeaders(httptest.NewRequest(http.MethodGet, "https://example.com", nil), "") var logBuf bytes.Buffer previousLogger := slog.Default() @@ -261,6 +263,50 @@ func TestCheckCLIAuthEnabled_TraceHeadersAndDebugLog(t *testing.T) { } } +func TestCrossPlatformCoverageInternationalLoginRegionRequestWrappers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case ClientIDPath: + _ = json.NewEncoder(w).Encode(ClientIDResponse{Success: true, Result: "international-client"}) + case SuperAdminPath: + _ = json.NewEncoder(w).Encode(SuperAdminResponse{Success: true, Result: []SuperAdmin{{StaffID: "admin"}}}) + case SendCliAuthApplyPath: + _ = json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + oldClient := oauthHTTPClient + oauthHTTPClient = server.Client() + restoreBaseURL := PushMCPBaseURLOverride(server.URL) + t.Cleanup(func() { + restoreBaseURL() + oauthHTTPClient = oldClient + }) + + ctx := context.Background() + for name, fetch := range map[string]func() (string, error){ + "device": func() (string, error) { return deviceFetchClientIDForLoginRegion(ctx, LoginRegionInternational) }, + "oauth": func() (string, error) { return oauthFetchClientIDForLoginRegion(ctx, LoginRegionInternational) }, + } { + if got, err := fetch(); err != nil || got != "international-client" { + t.Fatalf("%s client ID = %q, %v", name, got, err) + } + } + if got, err := deviceGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success { + t.Fatalf("device admins = %#v, %v", got, err) + } + if got, err := oauthGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success { + t.Fatalf("OAuth admins = %#v, %v", got, err) + } + if got, err := oauthSendApplyForLoginRegion(ctx, "token", "admin", LoginRegionInternational); err != nil || !got.Success { + t.Fatalf("OAuth apply = %#v, %v", got, err) + } +} + func TestCheckCLIAuthEnabled_Disabled(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go index 72d64c31..df517041 100644 --- a/internal/auth/device_flow_test.go +++ b/internal/auth/device_flow_test.go @@ -90,7 +90,7 @@ func TestRequestDeviceCodeSuccess(t *testing.T) { } } -func TestDeviceFlowSetLoginRegionUsesInternationalBaseURL(t *testing.T) { +func TestCrossPlatformCoverageDeviceFlowSetLoginRegionUsesInternationalBaseURL(t *testing.T) { provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger()) provider.SetLoginRegion(LoginRegionInternational) if provider.baseURL != InternationalDeviceBaseURL { diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 54878664..31a204cc 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -166,6 +166,9 @@ func MCPBaseURLForLoginRegion(region LoginRegion) string { } func UserAccessTokenURLForLoginRegion(region LoginRegion) string { + if override := LoginBaseURLOverride(); override != "" { + return override + "/v1.0/oauth2/userAccessToken" + } if region.IsInternational() { return InternationalUserAccessTokenURL } diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index a2a7d11c..60918b1b 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -386,7 +386,7 @@ func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing } } -func TestPrintJSONCLIOrgNotAuthorizedUsesInternationalActionURL(t *testing.T) { +func TestCrossPlatformCoveragePrintJSONCLIOrgNotAuthorizedUsesInternationalActionURL(t *testing.T) { dir := t.TempDir() t.Setenv("DWS_CONFIG_DIR", dir) if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), config.FilePerm); err != nil { diff --git a/internal/transport/client_extra_test.go b/internal/transport/client_extra_test.go index fc0da902..782e0006 100644 --- a/internal/transport/client_extra_test.go +++ b/internal/transport/client_extra_test.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "strings" "testing" ) @@ -234,7 +235,7 @@ func TestIsEndpointTrusted_DomainMatch(t *testing.T) { } } -func TestIsEndpointTrusted_DefaultIncludesDingTalkInternational(t *testing.T) { +func TestCrossPlatformCoverageIsEndpointTrustedDefaultIncludesDingTalkInternational(t *testing.T) { t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "") t.Setenv("DWS_TRUSTED_DOMAINS", "") c := NewClient(nil) @@ -244,6 +245,11 @@ func TestIsEndpointTrusted_DefaultIncludesDingTalkInternational(t *testing.T) { if !c.isEndpointTrusted("https://pre-mcp-gw.dingtalk.io/server/contact") { t.Fatal("default trusted domains should trust dingtalk.io") } + for _, host := range []string{"mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io"} { + if !shouldPreserveEndpointQuery(&url.URL{Scheme: "https", Host: host}) { + t.Fatalf("international gateway %q should preserve endpoint query", host) + } + } } func TestHttpStatusError(t *testing.T) { diff --git a/pkg/config/constants_test.go b/pkg/config/constants_test.go index 2c72b065..7296b231 100644 --- a/pkg/config/constants_test.go +++ b/pkg/config/constants_test.go @@ -219,7 +219,7 @@ func TestGetMCPBaseURLUsesConfigFile(t *testing.T) { } } -func TestGetDeveloperSettingsURLUsesInternationalMCPRegion(t *testing.T) { +func TestCrossPlatformCoverageGetDeveloperSettingsURLUsesInternationalMCPRegion(t *testing.T) { dir := t.TempDir() t.Setenv("DWS_CONFIG_DIR", dir) if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), FilePerm); err != nil { @@ -232,7 +232,7 @@ func TestGetDeveloperSettingsURLUsesInternationalMCPRegion(t *testing.T) { } } -func TestGetTerminalBaseURLPrefersExplicitConfig(t *testing.T) { +func TestCrossPlatformCoverageGetTerminalBaseURLPrefersExplicitConfig(t *testing.T) { dir := t.TempDir() t.Setenv("DWS_CONFIG_DIR", dir) if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), FilePerm); err != nil {