// Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package app import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "runtime" "strings" "testing" "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) func TestAuthExportImportBase64RoundTrip(t *testing.T) { originalSupported := authPortableExportSupported originalReady := authPortableSourceReady originalExport := authExportPortableBundle originalTarget := authPortableTargetPopulated originalImport := authImportPortableBundle t.Cleanup(func() { authPortableExportSupported = originalSupported authPortableSourceReady = originalReady authExportPortableBundle = originalExport authPortableTargetPopulated = originalTarget authImportPortableBundle = originalImport }) t.Setenv("DWS_CONFIG_DIR", t.TempDir()) bundle := []byte("portable-auth-bundle") authPortableExportSupported = func() bool { return true } authPortableSourceReady = func() bool { return true } authExportPortableBundle = func(_ string, w io.Writer) error { _, err := w.Write(bundle) return err } exportCmd := newAuthExportCommandWithSupport(func() error { return nil }) var exported bytes.Buffer exportCmd.SetOut(&exported) exportCmd.SetErr(&bytes.Buffer{}) exportCmd.SetArgs([]string{"--base64"}) if err := exportCmd.Execute(); err != nil { t.Fatalf("auth export --base64 error = %v", err) } if strings.TrimSpace(exported.String()) == "" { t.Fatal("auth export --base64 produced empty output") } targetRoot := t.TempDir() inputPath := filepath.Join(targetRoot, "dws-auth.b64") if err := os.WriteFile(inputPath, exported.Bytes(), 0o600); err != nil { t.Fatalf("write input bundle error = %v", err) } authPortableTargetPopulated = func(string) bool { return false } var imported []byte authImportPortableBundle = func(_ string, r io.Reader) (authpkg.PortableImportReport, error) { var err error imported, err = io.ReadAll(r) return authpkg.PortableImportReport{}, err } importCmd := newAuthImportCommandWithSupport(func() error { return nil }) importCmd.SetOut(&bytes.Buffer{}) importCmd.SetErr(&bytes.Buffer{}) importCmd.SetArgs([]string{"--input", inputPath, "--base64"}) if err := importCmd.Execute(); err != nil { t.Fatalf("auth import --base64 error = %v", err) } if !bytes.Equal(imported, bundle) { t.Fatalf("imported bundle = %q, want %q", imported, bundle) } } func TestCrossPlatformCoverageAuthExportUnsupportedBackendIsValidationError(t *testing.T) { exportCmd := newAuthExportCommandWithSupport(func() error { return errors.New("portable auth export is unavailable for the test backend") }) exportCmd.SetOut(&bytes.Buffer{}) exportCmd.SetErr(&bytes.Buffer{}) exportCmd.SetArgs([]string{"--base64"}) err := exportCmd.Execute() if err == nil { t.Fatal("auth export should reject an unsupported credential backend") } var appErr *apperrors.Error if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { t.Fatalf("expected validation error, got %T: %v", err, err) } if !strings.Contains(err.Error(), "test backend") { t.Fatalf("error = %v, want backend-specific reason", err) } } func TestCrossPlatformCoverageAuthExportRejectsWindowsDPAPIBackend(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows DPAPI contract requires a native Windows runner") } t.Cleanup(CloseFileLogger) exportCmd := NewRootCommand() exportCmd.SetOut(&bytes.Buffer{}) exportCmd.SetErr(&bytes.Buffer{}) exportCmd.SetArgs([]string{"auth", "export", "--base64"}) err := exportCmd.Execute() if err == nil { t.Fatal("auth export should reject the Windows DPAPI backend") } var appErr *apperrors.Error if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { t.Fatalf("expected validation error, got %T: %v", err, err) } for _, want := range []string{"Windows", "DPAPI", "HKCU"} { if !strings.Contains(err.Error(), want) { t.Fatalf("error = %v, want substring %q", err, want) } } } func TestCrossPlatformCoverageAuthImportUnsupportedBackendIsValidationErrorBeforeReadingInput(t *testing.T) { root := t.TempDir() configDir := filepath.Join(root, ".dws") keychainDir := filepath.Join(root, "keychain") t.Setenv("DWS_CONFIG_DIR", configDir) t.Setenv(keychain.StorageDirEnv, keychainDir) importCmd := newAuthImportCommandWithSupport(func() error { return errors.New("portable auth import is unavailable for the test backend") }) importCmd.SetOut(&bytes.Buffer{}) importCmd.SetErr(&bytes.Buffer{}) importCmd.SetArgs([]string{"--input", filepath.Join(root, "missing-bundle.tar.gz")}) err := importCmd.Execute() if err == nil { t.Fatal("auth import should reject an unsupported credential backend") } var appErr *apperrors.Error if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { t.Fatalf("expected validation error, got %T: %v", err, err) } if !strings.Contains(err.Error(), "test backend") { t.Fatalf("error = %v, want backend-specific reason", err) } for _, path := range []string{configDir, keychainDir} { if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { t.Fatalf("unsupported import touched %s: stat error = %v", path, statErr) } } } func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackend(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows DPAPI contract requires a native Windows runner") } root := t.TempDir() // NewRootCommand initializes the normal CLI file logger below configDir. // Register its cleanup after TempDir so the Windows handle is closed before // testing removes the temporary directory. t.Cleanup(CloseFileLogger) configDir := filepath.Join(root, ".dws") keychainDir := filepath.Join(root, "keychain") inputPath := filepath.Join(root, "bundle.tar.gz") if err := os.WriteFile(inputPath, []byte("the capability guard must run before this input is read"), 0o600); err != nil { t.Fatalf("write input sentinel error = %v", err) } t.Setenv("DWS_CONFIG_DIR", configDir) t.Setenv(keychain.StorageDirEnv, keychainDir) // Windows stores credentials in HKCU rather than StorageDirEnv. Use a // fresh registry namespace so this zero-state assertion cannot inherit a // token from an earlier test in the same package binary. t.Setenv(keychain.TestNamespaceEnv, root) t.Cleanup(func() { if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil { t.Errorf("clean import guard keychain fixture: %v", err) } }) importCmd := NewRootCommand() importCmd.SetOut(&bytes.Buffer{}) importCmd.SetErr(&bytes.Buffer{}) importCmd.SetArgs([]string{"auth", "import", "--input", inputPath}) err := importCmd.Execute() if err == nil { t.Fatal("auth import should reject the Windows DPAPI backend") } var appErr *apperrors.Error if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { t.Fatalf("expected validation error, got %T: %v", err, err) } for _, want := range []string{"Windows", "DPAPI", "HKCU"} { if !strings.Contains(err.Error(), want) { t.Fatalf("error = %v, want substring %q", err, want) } } // The root command may create configDir/logs as part of normal CLI startup. // The capability guard must still run before any auth state is imported. for _, path := range []string{ keychainDir, authpkg.ProfilesPath(configDir), filepath.Join(configDir, "app.json"), filepath.Join(configDir, "token.json"), } { if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { t.Fatalf("unsupported Windows import touched %s: stat error = %v", path, statErr) } } } func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackendWithPopulatedCredentialBeforeRead(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows DPAPI contract requires a native Windows runner") } previous, previousErr := authpkg.LoadTokenDataKeychain() if previousErr != nil && !errors.Is(previousErr, authpkg.ErrTokenDataNotFound) { t.Fatalf("capture existing Windows credential: %v", previousErr) } hadPrevious := previousErr == nil t.Cleanup(func() { if hadPrevious { _ = authpkg.SaveTokenDataKeychain(previous) } else { _ = authpkg.DeleteTokenDataKeychain() } }) want := &authpkg.TokenData{ AccessToken: "windows-existing-access", RefreshToken: "windows-existing-refresh", RefreshExpAt: time.Now().Add(24 * time.Hour), CorpID: "windows-existing-corp", } if err := authpkg.SaveTokenDataKeychain(want); err != nil { t.Fatalf("seed cleanup-scoped Windows DPAPI credential: %v", err) } originalTarget := authPortableTargetPopulated originalRead := authReadFile targetChecks := 0 bundleReads := 0 authPortableTargetPopulated = func(configDir string) bool { targetChecks++ return originalTarget(configDir) } authReadFile = func(path string) ([]byte, error) { bundleReads++ return originalRead(path) } t.Cleanup(func() { authPortableTargetPopulated = originalTarget authReadFile = originalRead }) root := t.TempDir() inputPath := filepath.Join(root, "bundle.tar.gz") if err := os.WriteFile(inputPath, []byte("unsupported Windows import must not read this bundle"), 0o600); err != nil { t.Fatalf("write bundle sentinel: %v", err) } t.Setenv("DWS_CONFIG_DIR", filepath.Join(root, ".dws")) importCmd := newAuthImportCommand() importCmd.SetOut(&bytes.Buffer{}) importCmd.SetErr(&bytes.Buffer{}) importCmd.SetArgs([]string{"--input", inputPath}) err := importCmd.Execute() if err == nil { t.Fatal("auth import should reject a populated Windows DPAPI backend") } var appErr *apperrors.Error if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { t.Fatalf("expected validation error, got %T: %v", err, err) } for _, required := range []string{"Windows", "DPAPI", "HKCU"} { if !strings.Contains(err.Error(), required) { t.Fatalf("error = %v, want substring %q", err, required) } } if strings.Contains(err.Error(), "--force") { t.Fatalf("unsupported Windows import suggested impossible --force remediation: %v", err) } if targetChecks != 0 || bundleReads != 0 { t.Fatalf("unsupported Windows import inspected credentials/bundle: target_checks=%d bundle_reads=%d", targetChecks, bundleReads) } got, err := authpkg.LoadTokenDataKeychain() if err != nil { t.Fatalf("reload Windows DPAPI credential after rejection: %v", err) } if got.AccessToken != want.AccessToken || got.RefreshToken != want.RefreshToken || got.CorpID != want.CorpID { t.Fatalf("Windows auth state changed after rejected import: got=%#v want=%#v", got, want) } } func TestCrossPlatformCoverageAuthImportRequiresForceWhenPopulated(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") root := t.TempDir() t.Cleanup(CloseFileLogger) configDir := filepath.Join(root, ".dws") t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain")) t.Setenv("DWS_CONFIG_DIR", configDir) if err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{ AccessToken: "existing", RefreshToken: "existing-refresh", RefreshExpAt: time.Now().Add(24 * time.Hour), }); err != nil { t.Fatalf("SaveTokenData() error = %v", err) } bundlePath := filepath.Join(root, "bundle.tar.gz") if err := os.WriteFile(bundlePath, []byte("not-a-real-bundle"), 0o600); err != nil { t.Fatalf("write bundle stub error = %v", err) } importCmd := newAuthImportCommandWithSupport(func() error { return nil }) var stderr bytes.Buffer importCmd.SetOut(&bytes.Buffer{}) importCmd.SetErr(&stderr) importCmd.SetArgs([]string{"--input", bundlePath}) err := importCmd.Execute() if err == nil { t.Fatal("auth import without --force should fail when auth exists") } var appErr *apperrors.Error if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { t.Fatalf("expected validation error, got %T: %v", err, err) } if !strings.Contains(err.Error(), "--force") { t.Fatalf("error = %v, want --force hint", err) } } func TestAuthImportRequiresForceWhenPopulated(t *testing.T) { originalTarget := authPortableTargetPopulated authPortableTargetPopulated = func(string) bool { return true } t.Cleanup(func() { authPortableTargetPopulated = originalTarget }) root := t.TempDir() configDir := filepath.Join(root, ".dws") t.Setenv("DWS_CONFIG_DIR", configDir) bundlePath := filepath.Join(root, "bundle.tar.gz") if err := os.WriteFile(bundlePath, []byte("not-a-real-bundle"), 0o600); err != nil { t.Fatalf("write bundle stub error = %v", err) } importCmd := newAuthImportCommandWithSupport(func() error { return nil }) importCmd.SetOut(&bytes.Buffer{}) importCmd.SetErr(&bytes.Buffer{}) importCmd.SetArgs([]string{"--input", bundlePath}) err := importCmd.Execute() if err == nil { t.Fatal("auth import without --force should fail when auth exists") } var appErr *apperrors.Error if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { t.Fatalf("expected validation error, got %T: %v", err, err) } if !strings.Contains(err.Error(), "--force") { t.Fatalf("error = %v, want --force hint", err) } } func TestAuthStatusJSONReportsKeychainUnavailable(t *testing.T) { t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config")) prev := edition.Get() edition.Override(&edition.Hooks{ LoadToken: func(configDir string) ([]byte, error) { return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing")) }, }) t.Cleanup(func() { edition.Override(prev) }) cmd := NewRootCommand() var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"--format", "json", "auth", "status"}) if err := cmd.Execute(); err != nil { t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String()) } var resp struct { Success bool `json:"success"` Authenticated bool `json:"authenticated"` Reason string `json:"reason"` Message string `json:"message"` Hint string `json:"hint"` } if err := json.Unmarshal(out.Bytes(), &resp); err != nil { t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String()) } if !resp.Success { t.Fatalf("success = false, want true; response=%+v", resp) } if resp.Authenticated { t.Fatalf("authenticated = true, want false; response=%+v", resp) } if resp.Reason != "keychain_unavailable" { t.Fatalf("reason = %q, want keychain_unavailable; response=%+v", resp.Reason, resp) } if !strings.Contains(resp.Message, "Keychain") && !strings.Contains(resp.Message, "钥匙串") { t.Fatalf("message should mention Keychain/钥匙串; response=%+v", resp) } if !strings.Contains(resp.Hint, keychain.DisableKeychainEnv) { t.Fatalf("hint should mention %s; response=%+v", keychain.DisableKeychainEnv, resp) } } func TestAuthStatusJSONReportsDEKMissing(t *testing.T) { t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config")) prev := edition.Get() edition.Override(&edition.Hooks{ LoadToken: func(configDir string) ([]byte, error) { return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing) }, }) t.Cleanup(func() { edition.Override(prev) }) cmd := NewRootCommand() var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"--format", "json", "auth", "status"}) if err := cmd.Execute(); err != nil { t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String()) } var resp struct { Success bool `json:"success"` Authenticated bool `json:"authenticated"` Reason string `json:"reason"` Message string `json:"message"` Hint string `json:"hint"` } if err := json.Unmarshal(out.Bytes(), &resp); err != nil { t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String()) } if !resp.Success { t.Fatalf("success = false, want true; response=%+v", resp) } if resp.Authenticated { t.Fatalf("authenticated = true, want false; response=%+v", resp) } if resp.Reason != "dek_missing" { t.Fatalf("reason = %q, want dek_missing; response=%+v", resp.Reason, resp) } if !strings.Contains(resp.Message, "登录密钥") { t.Fatalf("message should mention 登录密钥; response=%+v", resp) } if !strings.Contains(resp.Hint, "重新登录") { t.Fatalf("hint should mention 重新登录; response=%+v", resp) } if !strings.Contains(resp.Hint, "dws auth reset") { t.Fatalf("hint should mention dws auth reset; response=%+v", resp) } } func TestAuthStatusDiagnosticReportsCiphertextKeyMismatch(t *testing.T) { diagnostic := authStatusDiagnosticFromError(fmt.Errorf("load token: %w", keychain.ErrCiphertextKeyMismatch)) if diagnostic == nil { t.Fatal("authStatusDiagnosticFromError() = nil") } if diagnostic.Reason != "ciphertext_key_mismatch" { t.Fatalf("reason = %q, want ciphertext_key_mismatch", diagnostic.Reason) } if !strings.Contains(diagnostic.Hint, keychain.DisableKeychainEnv) { t.Fatalf("hint should mention %s: %q", keychain.DisableKeychainEnv, diagnostic.Hint) } } func TestAuthStatusRefreshFailureReportsUnauthenticatedDiagnostic(t *testing.T) { // Isolate keychain storage to a per-test directory so the saved // token can't leak into other test packages running in parallel. t.Setenv(keychain.StorageDirEnv, t.TempDir()) t.Cleanup(func() { _ = keychain.Remove(keychain.Service, keychain.AccountToken) }) root := t.TempDir() configDir := filepath.Join(root, "config") t.Setenv("DWS_CONFIG_DIR", configDir) err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{ AccessToken: "expired-access", RefreshToken: "refresh-123", ExpiresAt: time.Now().Add(-time.Hour), RefreshExpAt: time.Now().Add(24 * time.Hour), CorpID: "dingcorp", UserID: "user-dingcorp", ClientID: "client-dingcorp", Source: "mcp", }) if err != nil { t.Skipf("SaveTokenData() unavailable in this environment: %v", err) } originalTransport := http.DefaultTransport t.Cleanup(func() { http.DefaultTransport = originalTransport }) http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { return nil, errors.New("refresh failed") }) cmd := NewRootCommand() var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"--format", "json", "auth", "status"}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } // Verify token data still exists in keychain after refresh failure if !authpkg.TokenDataExistsKeychain() { t.Fatal("secure token data should remain in keychain after refresh failure") } var resp authStatusResponse if err := json.Unmarshal(out.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String()) } if resp.Authenticated { t.Fatalf("authenticated = true after refresh failure: %+v", resp) } if resp.Reason != "token_refresh_failed" { t.Fatalf("reason = %q, want token_refresh_failed: %+v", resp.Reason, resp) } if !strings.Contains(resp.Message, "refresh failed") { t.Fatalf("message = %q, want original refresh failure", resp.Message) } } func TestAuthStatusTableIncludesCorpName(t *testing.T) { setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary")) cmd := NewRootCommand() var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"--format", "table", "auth", "status"}) if err := cmd.Execute(); err != nil { t.Fatalf("auth status --format table error = %v\noutput:\n%s", err, out.String()) } for _, want := range []string{"企业:", "corp_primary org", "企业 ID:", "corp_primary"} { if !bytes.Contains(out.Bytes(), []byte(want)) { t.Fatalf("auth status table missing %q in output:\n%s", want, out.String()) } } } func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) { configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"), authLogoutTestToken("corp_secondary"), ) cmd := NewRootCommand() var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"--format", "table", "auth", "status", "--profile", "corp_primary"}) if err := cmd.Execute(); err != nil { t.Fatalf("auth status --profile error = %v\noutput:\n%s", err, out.String()) } for _, want := range []string{"corp_primary org", "corp_primary"} { if !bytes.Contains(out.Bytes(), []byte(want)) { t.Fatalf("auth status --profile output missing %q:\n%s", want, out.String()) } } if bytes.Contains(out.Bytes(), []byte("corp_secondary org")) { t.Fatalf("auth status --profile should render selected profile, got:\n%s", out.String()) } cfg, err := authpkg.LoadProfiles(configDir) if err != nil { t.Fatalf("LoadProfiles() error = %v", err) } if cfg.CurrentProfile != "corp_secondary:user-corp_secondary" { t.Fatalf("currentProfile = %q, want unchanged exact secondary identity", cfg.CurrentProfile) } } func TestAuthStatusRejectsAmbiguousProfileSelector(t *testing.T) { first := authLogoutTestToken("corp_first") first.CorpName = "Shared Org" second := authLogoutTestToken("corp_second") second.CorpName = "Shared Org" setupAuthLogoutProfiles(t, first, second) cmd := NewRootCommand() var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"--format", "json", "auth", "status", "--profile", "Shared Org"}) err := cmd.Execute() if err == nil { t.Fatalf("auth status accepted ambiguous profile selector\noutput:\n%s", out.String()) } var appErr *apperrors.Error if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { t.Fatalf("error = %T %v, want validation error", err, err) } for _, candidate := range []string{"corp_first", "corp_second"} { if !strings.Contains(err.Error(), candidate) { t.Fatalf("error = %q, want candidate %q", err.Error(), candidate) } } } func TestAuthMigrateKeychainDryRunAndConfirmedExecution(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "") oldMigrate := migrateKeychainToFileDEK t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate }) calls := 0 migrateKeychainToFileDEK = func(_ string, dryRun bool) (int, error) { calls++ if calls == 1 && !dryRun { t.Fatal("first migration call should be dry-run") } if calls == 2 && dryRun { t.Fatal("second migration call should execute") } return 4, nil } newRoot := func() (*cobra.Command, *bytes.Buffer) { root := &cobra.Command{Use: "dws"} root.PersistentFlags().Bool("dry-run", false, "") root.PersistentFlags().Bool("yes", false, "") root.PersistentFlags().String("format", "json", "") root.AddCommand(newAuthMigrateKeychainCommand()) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) return root, &out } root, out := newRoot() root.SetArgs([]string{"migrate-keychain", "--dry-run"}) if err := root.Execute(); err != nil { t.Fatalf("migrate-keychain --dry-run error = %v\noutput:\n%s", err, out.String()) } if !strings.Contains(out.String(), `"dry_run":true`) || !strings.Contains(out.String(), `"entries":4`) { t.Fatalf("dry-run output = %q", out.String()) } root, out = newRoot() root.SetArgs([]string{"migrate-keychain", "--yes"}) if err := root.Execute(); err != nil { t.Fatalf("migrate-keychain --yes error = %v\noutput:\n%s", err, out.String()) } if !strings.Contains(out.String(), `"dry_run":false`) || !strings.Contains(out.String(), `"entries":4`) { t.Fatalf("migration output = %q", out.String()) } if calls != 2 { t.Fatalf("migration calls = %d, want 2", calls) } } func TestAuthMigrateKeychainRequiresConfirmationAndSystemMode(t *testing.T) { oldMigrate := migrateKeychainToFileDEK t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate }) migrateKeychainToFileDEK = func(_ string, _ bool) (int, error) { t.Fatal("migration backend should not be called") return 0, nil } newRoot := func() *cobra.Command { root := &cobra.Command{Use: "dws"} root.PersistentFlags().Bool("dry-run", false, "") root.PersistentFlags().Bool("yes", false, "") root.PersistentFlags().String("format", "json", "") root.AddCommand(newAuthMigrateKeychainCommand()) root.SetOut(io.Discard) root.SetErr(io.Discard) return root } t.Setenv(keychain.DisableKeychainEnv, "") root := newRoot() root.SetArgs([]string{"migrate-keychain"}) if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--yes") { t.Fatalf("unconfirmed migration error = %v, want --yes guidance", err) } t.Setenv(keychain.DisableKeychainEnv, "1") root = newRoot() root.SetArgs([]string{"migrate-keychain", "--dry-run"}) if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "env -u") { t.Fatalf("file-DEK mode migration error = %v, want system-mode guidance", err) } } func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) { configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"), authLogoutTestToken("corp_secondary"), ) if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{ ClientID: "client-app", ClientSecret: authpkg.PlainSecret("secret-app"), }); err != nil { t.Fatalf("SaveAppConfig() error = %v", err) } originalTransport := http.DefaultTransport t.Cleanup(func() { http.DefaultTransport = originalTransport }) http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { return nil, errors.New("remote revoke disabled in unit test") }) cmd := NewRootCommand() var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"auth", "logout"}) if err := cmd.Execute(); err != nil { t.Fatalf("auth logout error = %v\noutput:\n%s", err, out.String()) } for _, want := range []string{"[OK] 已清除认证信息", "重新登录"} { if !strings.Contains(out.String(), want) { t.Fatalf("auth logout output missing %q:\n%s", want, out.String()) } } cfg, err := authpkg.LoadProfiles(configDir) if err != nil { t.Fatalf("LoadProfiles() error = %v", err) } if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 { t.Fatalf("profiles after logout = %#v, want empty", cfg) } if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") { t.Fatal("primary profile token should be deleted") } if authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") { t.Fatal("secondary profile token should be deleted") } if authpkg.TokenDataExistsKeychain() { t.Fatal("legacy auth-token mirror should be deleted") } appConfig, err := authpkg.LoadAppConfig(configDir) if err != nil { t.Fatalf("LoadAppConfig() error = %v", err) } if appConfig == nil || appConfig.ClientID != "client-app" { t.Fatalf("app config after logout = %#v, want preserved client-app", appConfig) } } func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) { configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"), authLogoutTestToken("corp_secondary"), ) originalTransport := http.DefaultTransport t.Cleanup(func() { http.DefaultTransport = originalTransport }) http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { return nil, errors.New("remote revoke disabled in unit test") }) cmd := NewRootCommand() var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"auth", "logout", "--profile", "corp_primary"}) if err := cmd.Execute(); err != nil { t.Fatalf("auth logout --profile corp_primary error = %v\noutput:\n%s", err, out.String()) } cfg, err := authpkg.LoadProfiles(configDir) if err != nil { t.Fatalf("LoadProfiles() error = %v", err) } if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "corp_secondary:user-corp_secondary" { t.Fatalf("profiles pointers = primary %q current %q", cfg.PrimaryProfile, cfg.CurrentProfile) } if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_secondary" { t.Fatalf("profiles = %#v, want only corp_secondary retained", cfg.Profiles) } if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") { t.Fatal("selected primary profile token should be deleted") } if !authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") { t.Fatal("unselected secondary profile token should be retained") } loaded, err := authpkg.LoadTokenData(configDir) if err != nil { t.Fatalf("LoadTokenData() error = %v", err) } if loaded.CorpID != "corp_secondary" || loaded.AccessToken != "access-corp_secondary" { t.Fatalf("default token = (%q, %q), want retained secondary token", loaded.CorpID, loaded.AccessToken) } } func TestAuthLogoutExactProfilePreservesSameCorpAccount(t *testing.T) { first := authLogoutTestToken("corp_same") first.UserID = "user_1" second := authLogoutTestToken("corp_same") second.AccessToken = "access-second" second.RefreshToken = "refresh-second" second.UserID = "user_2" configDir := setupAuthLogoutProfiles(t, first, second) originalTransport := http.DefaultTransport t.Cleanup(func() { http.DefaultTransport = originalTransport }) http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { return nil, errors.New("remote revoke disabled in unit test") }) cmd := NewRootCommand() var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"auth", "logout", "--profile", "corp_same:user_2"}) if err := cmd.Execute(); err != nil { t.Fatalf("auth logout exact profile error = %v\noutput:\n%s", err, out.String()) } cfg, err := authpkg.LoadProfiles(configDir) if err != nil { t.Fatalf("LoadProfiles() error = %v", err) } if len(cfg.Profiles) != 1 || cfg.Profiles[0].UserID != "user_1" { t.Fatalf("profiles = %#v, want only user_1 retained", cfg.Profiles) } if authpkg.TokenDataExistsKeychainForIdentity("corp_same", "user_2") { t.Fatal("selected identity token should be deleted") } loaded, err := authpkg.LoadTokenDataForProfile(configDir, "corp_same") if err != nil { t.Fatalf("LoadTokenDataForProfile(org) error = %v", err) } if loaded.UserID != "user_1" || loaded.AccessToken != first.AccessToken { t.Fatalf("org current token = %#v, want retained user_1", loaded) } } func TestAuthLogoutLocalProfileNameRevokesOnlySelectedAccount(t *testing.T) { first := authLogoutTestToken("corp_same") first.UserID = "user_1" first.UserName = "账号一" second := authLogoutTestToken("corp_same") second.AccessToken = "access-second" second.RefreshToken = "refresh-second" second.UserID = "user_2" second.UserName = "账号二" configDir := setupAuthLogoutProfiles(t, first, second) cfg, err := authpkg.LoadProfiles(configDir) if err != nil { t.Fatalf("LoadProfiles() error = %v", err) } var selector string for _, profile := range cfg.Profiles { if profile.UserID == "user_2" { selector = profile.Name } } if selector == "" || selector == second.CorpName { t.Fatalf("second local profile name = %q, want unique non-org alias", selector) } requests := 0 originalTransport := http.DefaultTransport t.Cleanup(func() { http.DefaultTransport = originalTransport }) http.DefaultTransport = roundTripFunc(func(*http.Request) (*http.Response, error) { requests++ return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("")), }, nil }) cmd := NewRootCommand() var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"auth", "logout", "--profile", selector}) if err := cmd.Execute(); err != nil { t.Fatalf("auth logout local profile error = %v\noutput:\n%s", err, out.String()) } if requests != 1 { t.Fatalf("remote revoke requests = %d, want 1", requests) } } func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) { newRoot := func(t *testing.T) *cobra.Command { t.Helper() root := &cobra.Command{Use: "dws"} root.PersistentFlags().String("format", "json", "") return root } t.Run("recommend skips tui but keeps human auth for interactive login", func(t *testing.T) { root := newRoot(t) if authLoginShouldShowPostLoginTUIForTerminal(root, "json", true, true) { t.Fatal("--recommend must not show the post-login product TUI") } if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) { t.Fatal("default interactive --recommend should still use human authorization flow") } }) t.Run("without recommend shows two-step authorization tui", func(t *testing.T) { root := newRoot(t) if !authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) { t.Fatal("default interactive login should show post-login authorization TUI") } if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) { t.Fatal("default interactive post-login authorization should use human authorization flow") } }) t.Run("explicit json keeps machine mode", func(t *testing.T) { root := newRoot(t) if err := root.PersistentFlags().Set("format", "json"); err != nil { t.Fatalf("set format: %v", err) } if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) { t.Fatal("explicit --format json must not show post-login TUI") } if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) { t.Fatal("explicit --format json must keep machine-readable authorization flow") } }) t.Run("table without recommend shows authorization tui", func(t *testing.T) { root := newRoot(t) if err := root.PersistentFlags().Set("format", "table"); err != nil { t.Fatalf("set format: %v", err) } if !authLoginShouldShowPostLoginTUIForTerminal(root, "table", false, true) { t.Fatal("table format should show post-login TUI without --recommend") } if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "table", true, true) { t.Fatal("table format should use human authorization flow in an interactive terminal") } }) t.Run("non interactive skips selector", func(t *testing.T) { root := newRoot(t) if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, false) { t.Fatal("non-interactive login should skip post-login TUI") } if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, false) { t.Fatal("non-interactive login should keep machine-readable authorization flow") } }) t.Run("without authorization flow keeps normal login output contract", func(t *testing.T) { root := newRoot(t) if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", false, true) { t.Fatal("login without a post-login authorization flow should not switch default json to human mode") } }) } func TestLoginRecommendProductLabelMatchesTUITarget(t *testing.T) { label := loginRecommendProductLabel(pat.LoginRecommendProduct{ ProductCode: "approval", ProductName: "审批", Summary: "审批实例,审批模板,审批任务管理", ScopeCount: 12, }) if label != "approval 审批 - 审批实例,审批模板,审批任务管理" { t.Fatalf("label = %q", label) } } func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) { t.Setenv("DWS_DEBUG_AUTH", "1") var logs bytes.Buffer previousLogger := slog.Default() slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))) t.Cleanup(func() { slog.SetDefault(previousLogger) }) root := &cobra.Command{Use: "dws"} root.PersistentFlags().Bool("yes", false, "") root.PersistentFlags().String("profile", "", "") login := &cobra.Command{Use: "login"} login.Flags().String("token", "", "") login.Flags().Bool("device", false, "") login.Flags().Bool("intl", false, "") login.Flags().Bool("international", false, "") login.Flags().Bool("force", false, "") login.Flags().Bool("recommend", false, "") login.Flags().String("pre-url", "", "") login.Flags().String("mcp-url", "", "") root.AddCommand(login) if err := root.PersistentFlags().Set("yes", "true"); err != nil { t.Fatalf("set yes: %v", err) } if err := login.Flags().Set("recommend", "true"); err != nil { t.Fatalf("set recommend: %v", err) } cfg, err := resolveAuthLoginConfig(login) if err != nil { t.Fatalf("resolveAuthLoginConfig error = %v", err) } if !cfg.Recommend { t.Fatal("Recommend = false, want true") } if !cfg.Yes { t.Fatal("Yes = false, want true") } if got := logs.String(); !strings.Contains(got, `"msg":"auth.login.request"`) || !strings.Contains(got, `"profile_selector":""`) || !strings.Contains(got, `"target_corp_id":""`) { t.Fatalf("login request diagnostic log missing selector resolution:\n%s", got) } } func TestCrossPlatformCoverageResolveAuthLoginConfigReadsInternationalAliases(t *testing.T) { for _, flag := range []string{"intl", "international"} { t.Run(flag, func(t *testing.T) { root := &cobra.Command{Use: "dws"} root.PersistentFlags().Bool("yes", false, "") login := &cobra.Command{Use: "login"} login.Flags().String("token", "", "") login.Flags().Bool("device", false, "") login.Flags().Bool("intl", false, "") login.Flags().Bool("international", false, "") login.Flags().Bool("force", false, "") login.Flags().Bool("recommend", false, "") login.Flags().String("pre-url", "", "") login.Flags().String("mcp-url", "", "") root.AddCommand(login) if err := login.Flags().Set(flag, "true"); err != nil { t.Fatalf("set %s: %v", flag, err) } cfg, err := resolveAuthLoginConfig(login) if err != nil { t.Fatalf("resolveAuthLoginConfig error = %v", err) } if !cfg.International { t.Fatalf("International = false for --%s, want true", flag) } }) } 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 TestCrossPlatformCoverageResolveAuthLoginConfigReadsMCPURL(t *testing.T) { root := &cobra.Command{Use: "dws"} root.PersistentFlags().Bool("yes", false, "") login := &cobra.Command{Use: "login"} login.Flags().String("token", "", "") login.Flags().Bool("device", false, "") login.Flags().Bool("intl", false, "") login.Flags().Bool("international", false, "") login.Flags().Bool("force", false, "") login.Flags().Bool("recommend", false, "") login.Flags().String("pre-url", "", "") login.Flags().String("mcp-url", "", "") root.AddCommand(login) if err := login.Flags().Set("mcp-url", " https://pre-mcp.dingtalk.io/ "); err != nil { t.Fatalf("set mcp-url: %v", err) } cfg, err := resolveAuthLoginConfig(login) if err != nil { t.Fatalf("resolveAuthLoginConfig error = %v", err) } if cfg.MCPURL != "https://pre-mcp.dingtalk.io/" { t.Fatalf("MCPURL = %q, want trimmed flag value", cfg.MCPURL) } } func TestCrossPlatformCoverageResolveAuthLoginConfigReadsPreURL(t *testing.T) { root := &cobra.Command{Use: "dws"} root.PersistentFlags().Bool("yes", false, "") login := &cobra.Command{Use: "login"} login.Flags().String("token", "", "") login.Flags().Bool("device", false, "") login.Flags().Bool("intl", false, "") login.Flags().Bool("international", false, "") login.Flags().Bool("force", false, "") login.Flags().Bool("recommend", false, "") login.Flags().String("pre-url", "", "") login.Flags().String("mcp-url", "", "") root.AddCommand(login) if err := login.Flags().Set("pre-url", " pre-login.dingtalk.io "); err != nil { t.Fatalf("set pre-url: %v", err) } cfg, err := resolveAuthLoginConfig(login) if err != nil { t.Fatalf("resolveAuthLoginConfig error = %v", err) } if cfg.PreURL != "pre-login.dingtalk.io" { t.Fatalf("PreURL = %q, want trimmed flag value", cfg.PreURL) } } func TestCrossPlatformCoverageAuthLoginEndpointOverridesForPreURL(t *testing.T) { tests := []struct { name string raw string wantLogin string wantMCP string }{ { name: "pre login", raw: "https://pre-login.dingtalk.io/", wantLogin: "https://pre-login.dingtalk.io", wantMCP: "https://pre-mcp.dingtalk.io", }, { name: "pre mcp", raw: "pre-mcp.dingtalk.io", 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 { t.Run(tc.name, func(t *testing.T) { got, err := authLoginEndpointOverridesForPreURL(tc.raw) if err != nil { t.Fatalf("authLoginEndpointOverridesForPreURL error = %v", err) } if got.LoginURL != tc.wantLogin || got.MCPURL != tc.wantMCP { t.Fatalf("overrides = %#v, want login %q mcp %q", got, tc.wantLogin, tc.wantMCP) } }) } 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 TestCrossPlatformCoverageAuthLoginMCPBaseURLForConfig(t *testing.T) { tests := []struct { name string cfg authLoginConfig preOverride authLoginEndpointOverrides wantURL string wantPersistence authLoginMCPPersistence }{ { name: "default center login uses com and resets only managed region", cfg: authLoginConfig{}, wantURL: authpkg.DefaultMCPBaseURL, wantPersistence: authLoginMCPUseDefault, }, { name: "international login persists managed io", cfg: authLoginConfig{International: true}, wantURL: authpkg.InternationalMCPBaseURL, wantPersistence: authLoginMCPUseManagedRegion, }, { name: "pre login persists mapped pre mcp", cfg: authLoginConfig{PreURL: "pre-login.dingtalk.io"}, preOverride: authLoginEndpointOverrides{ LoginURL: "https://pre-login.dingtalk.io", MCPURL: "https://pre-mcp.dingtalk.io", }, wantURL: "https://pre-mcp.dingtalk.io", wantPersistence: authLoginMCPUseExplicitOverride, }, { name: "explicit mcp url wins over pre url", cfg: authLoginConfig{ PreURL: "pre-login.dingtalk.io", MCPURL: " https://custom-mcp.example.com/ ", }, preOverride: authLoginEndpointOverrides{ LoginURL: "https://pre-login.dingtalk.io", MCPURL: "https://pre-mcp.dingtalk.io", }, wantURL: "https://custom-mcp.example.com", wantPersistence: authLoginMCPUseExplicitOverride, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { gotURL, gotPersistence, err := authLoginMCPBaseURLForConfig(tc.cfg, tc.preOverride) if err != nil { t.Fatalf("authLoginMCPBaseURLForConfig error = %v", err) } if gotURL != tc.wantURL || gotPersistence != tc.wantPersistence { t.Fatalf("got url=%q persistence=%v, want url=%q persistence=%v", gotURL, gotPersistence, tc.wantURL, tc.wantPersistence) } }) } 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 TestCrossPlatformCoveragePersistAuthLoginMCPBaseURL(t *testing.T) { t.Run("persists selected io mcp url", func(t *testing.T) { configDir := t.TempDir() if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err) } data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) if err != nil { t.Fatalf("ReadFile(mcp_url) error = %v", err) } if string(data) != authpkg.InternationalMCPBaseURL { t.Fatalf("mcp_url = %q, want %q", string(data), authpkg.InternationalMCPBaseURL) } managed, err := os.ReadFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)) if err != nil || string(managed) != authpkg.InternationalMCPBaseURL { t.Fatalf("managed MCP region = %q, %v", string(managed), err) } }) t.Run("center login preserves previous persisted override", func(t *testing.T) { configDir := t.TempDir() mcpURLPath := filepath.Join(configDir, "mcp_url") 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, authLoginMCPUseDefault); err != nil { t.Fatalf("persistAuthLoginMCPBaseURL error = %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) } }) t.Run("international login preserves an unmanaged explicit override", func(t *testing.T) { configDir := t.TempDir() mcpURLPath := filepath.Join(configDir, "mcp_url") const customURL = "https://private-mcp.example.com" if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil { t.Fatal(err) } if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { t.Fatal(err) } data, err := os.ReadFile(mcpURLPath) if err != nil || string(data) != customURL { t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err) } if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) { t.Fatalf("unmanaged override acquired a managed marker: %v", err) } }) t.Run("center login resets a managed international URL", func(t *testing.T) { configDir := t.TempDir() if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { t.Fatal(err) } if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) if err != nil || string(data) != authpkg.DefaultMCPBaseURL { t.Fatalf("mcp_url = %q, %v; want domestic default", string(data), err) } if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) { t.Fatalf("managed region marker remains after domestic login: %v", err) } }) t.Run("explicit override clears region ownership", func(t *testing.T) { configDir := t.TempDir() if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { t.Fatal(err) } const customURL = "https://custom-mcp.example.com" if err := persistAuthLoginMCPBaseURL(configDir, customURL, authLoginMCPUseExplicitOverride); err != nil { t.Fatal(err) } if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) if err != nil || string(data) != customURL { t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err) } }) t.Run("stale marker never deletes a different custom URL", func(t *testing.T) { configDir := t.TempDir() if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://custom.example.com"), config.FilePerm); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName), []byte(authpkg.InternationalMCPBaseURL), config.FilePerm); err != nil { t.Fatal(err) } if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) if err != nil || string(data) != "https://custom.example.com" { t.Fatalf("custom mcp_url = %q, %v", string(data), err) } }) t.Run("international login clears a stale marker without changing a custom URL", func(t *testing.T) { configDir := t.TempDir() mcpURLPath := filepath.Join(configDir, "mcp_url") managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName) const customURL = "https://private-mcp.example.com" if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil { t.Fatal(err) } if err := os.WriteFile(managedRegionPath, []byte(authpkg.DefaultMCPBaseURL), config.FilePerm); err != nil { t.Fatal(err) } if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { t.Fatal(err) } data, err := os.ReadFile(mcpURLPath) if err != nil || string(data) != customURL { t.Fatalf("custom mcp_url = %q, %v", string(data), err) } if _, err := os.Stat(managedRegionPath); !os.IsNotExist(err) { t.Fatalf("stale managed marker remains: %v", err) } }) } func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURLErrors(t *testing.T) { fail := errors.New("persist failure") t.Run("explicit marker cleanup", func(t *testing.T) { testseam.Swap(t, &authRemove, func(string) error { return fail }) if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) { t.Fatalf("explicit marker cleanup error = %v", err) } }) t.Run("explicit URL write", func(t *testing.T) { testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail }) if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) { t.Fatalf("explicit URL write error = %v", err) } }) t.Run("managed marker write", func(t *testing.T) { testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail }) if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) { t.Fatalf("managed marker write error = %v", err) } }) t.Run("managed marker read", func(t *testing.T) { testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail }) if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) { t.Fatalf("managed marker read error = %v", err) } }) t.Run("managed MCP URL read", func(t *testing.T) { reads := 0 testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { reads++ if reads == 1 { return nil, os.ErrNotExist } return nil, fail }) if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) { t.Fatalf("managed MCP URL read error = %v", err) } }) t.Run("managed stale marker cleanup", func(t *testing.T) { reads := 0 testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { reads++ if reads == 1 { return []byte(authpkg.DefaultMCPBaseURL), nil } return []byte("https://private-mcp.example.com"), nil }) testseam.Swap(t, &authRemove, func(string) error { return fail }) if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) { t.Fatalf("managed stale marker cleanup error = %v", err) } }) t.Run("managed URL write cleans marker", func(t *testing.T) { writes := 0 removed := false testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { writes++ if writes == 2 { return fail } return nil }) testseam.Swap(t, &authRemove, func(string) error { removed = true; return nil }) if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) || !removed { t.Fatalf("managed URL write error = %v, marker removed=%v", err, removed) } }) t.Run("default managed marker read", func(t *testing.T) { testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail }) if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) { t.Fatalf("managed marker read error = %v", err) } }) t.Run("missing MCP URL cleans marker", func(t *testing.T) { reads := 0 testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { reads++ if reads == 1 { return []byte(authpkg.InternationalMCPBaseURL), nil } return nil, os.ErrNotExist }) testseam.Swap(t, &authRemove, func(string) error { return nil }) if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { t.Fatalf("missing MCP URL cleanup error = %v", err) } }) t.Run("MCP URL read", func(t *testing.T) { reads := 0 testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { reads++ if reads == 1 { return []byte(authpkg.InternationalMCPBaseURL), nil } return nil, fail }) if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) { t.Fatalf("MCP URL read error = %v", err) } }) t.Run("default URL write", func(t *testing.T) { testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil }) testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail }) if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) { t.Fatalf("default URL write error = %v", err) } }) t.Run("final marker cleanup", func(t *testing.T) { testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil }) testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return nil }) testseam.Swap(t, &authRemove, func(string) error { return fail }) if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) { t.Fatalf("final marker cleanup error = %v", err) } }) if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPPersistence(255)); err == nil { t.Fatal("unsupported MCP persistence mode succeeded") } } 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") } if !authLoginForcesAuthorization(authLoginConfig{Force: false}) { t.Fatal("Force=false should still force authorization") } } func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, t.TempDir()) t.Setenv("DWS_CONFIG_DIR", t.TempDir()) oldGuideSelector := authLoginGuideActionSelector oldGuideApplier := authLoginGuideActionApplier oldScopeSelector := loginRecommendScopeModeSelector oldProductSelector := loginRecommendProductSelector oldInteractiveTerminal := authLoginInteractiveTerminal t.Cleanup(func() { authLoginGuideActionSelector = oldGuideSelector authLoginGuideActionApplier = oldGuideApplier loginRecommendScopeModeSelector = oldScopeSelector loginRecommendProductSelector = oldProductSelector authLoginInteractiveTerminal = oldInteractiveTerminal }) authLoginInteractiveTerminal = func() bool { return true } authLoginGuideActionSelector = func() (authLoginGuideAction, error) { t.Fatal("--recommend must not call the post-login guide selector") return "", nil } authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { t.Fatal("--recommend must not apply a post-login guide action") return nil } loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { t.Fatal("--recommend must not call the scope-mode TUI") return "", nil } loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { t.Fatal("--recommend must not call the product-domain TUI") return nil, nil } fake := &authLoginRecommendSequenceCaller{responses: []string{ `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, }} authpkg.SetRuntimeProfile("corp_old:user_old") t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) var authorizationProfiles []string fake.beforeCall = func(string) { authorizationProfiles = append(authorizationProfiles, authpkg.RuntimeProfile()) } cmd := newAuthLoginCommand(fake) var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"--token", "login-token", "--recommend"}) if err := cmd.Execute(); err != nil { t.Fatalf("auth login --recommend error = %v\noutput:\n%s", err, out.String()) } if len(fake.tools) != 2 { t.Fatalf("CallTool count = %d, want plan + grant", len(fake.tools)) } if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_grant" { t.Fatalf("tool sequence = %v, want plan, grant", fake.tools) } if got := fake.args[0]["recommend"]; got != true { t.Fatalf("--recommend plan recommend = %#v, want true", got) } for _, profile := range authorizationProfiles { if profile != "" { t.Fatalf("manual token post-login profile = %q, want empty runtime selector", profile) } } if got := authpkg.RuntimeProfile(); got != "corp_old:user_old" { t.Fatalf("runtime profile after authorization = %q, want restored selector", got) } } func TestAuthLoginRecommendUsesNewExactIdentity(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, t.TempDir()) t.Setenv("DWS_CONFIG_DIR", t.TempDir()) oldOAuthLogin := authOAuthLogin oldInteractive := authLoginInteractiveTerminal t.Cleanup(func() { authOAuthLogin = oldOAuthLogin authLoginInteractiveTerminal = oldInteractive authpkg.SetRuntimeProfile("") }) authLoginInteractiveTerminal = func() bool { return false } authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) { return &authpkg.TokenData{ AccessToken: "new-token", CorpID: "corp_same", UserID: "user_new", ExpiresAt: time.Now().Add(time.Hour), }, nil } fake := &authLoginRecommendSequenceCaller{responses: []string{ `{"success":true,"data":{"items":[],"selectedScopes":[]}}`, }} var authorizationProfiles []string fake.beforeCall = func(string) { authorizationProfiles = append(authorizationProfiles, authpkg.RuntimeProfile()) } authpkg.SetRuntimeProfile("corp_same:user_old") cmd := newAuthLoginCommand(fake) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) cmd.SetArgs([]string{"--recommend"}) if err := cmd.Execute(); err != nil { t.Fatalf("auth login --recommend error = %v", err) } for _, profile := range authorizationProfiles { if profile != "corp_same:user_new" { t.Fatalf("post-login authorization profile = %q, want new exact identity", profile) } } if got := authpkg.RuntimeProfile(); got != "corp_same:user_old" { t.Fatalf("runtime profile after authorization = %q, want restored old identity", got) } } func TestAuthLoginDefaultTUIModeSkipsSelectorWhenAllGranted(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, t.TempDir()) t.Setenv("DWS_CONFIG_DIR", t.TempDir()) oldGuideSelector := authLoginGuideActionSelector oldGuideApplier := authLoginGuideActionApplier oldScopeSelector := loginRecommendScopeModeSelector oldProductSelector := loginRecommendProductSelector oldInteractiveTerminal := authLoginInteractiveTerminal t.Cleanup(func() { authLoginGuideActionSelector = oldGuideSelector authLoginGuideActionApplier = oldGuideApplier loginRecommendScopeModeSelector = oldScopeSelector loginRecommendProductSelector = oldProductSelector authLoginInteractiveTerminal = oldInteractiveTerminal }) authLoginInteractiveTerminal = func() bool { return true } authLoginGuideActionSelector = func() (authLoginGuideAction, error) { t.Fatal("default auth login must not call the operation guide selector") return "", nil } authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { t.Fatal("default auth login must not apply a post-login guide action") return nil } loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { t.Fatal("all-granted recommend plan must not call the scope-mode TUI") return "", nil } loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { t.Fatal("all-granted recommend plan must not call the product-domain TUI") return nil, nil } fake := &authLoginRecommendSequenceCaller{responses: []string{ `{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`, }} cmd := newAuthLoginCommand(fake) var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"--token", "login-token"}) if err := cmd.Execute(); err != nil { t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String()) } if len(fake.tools) != 1 { t.Fatalf("CallTool count = %d, want only preflight plan", len(fake.tools)) } if fake.tools[0] != "pat.batch_plan" { t.Fatalf("tool sequence = %v, want only plan", fake.tools) } if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") { t.Fatalf("output = %q, want all-granted message", out.String()) } } func TestAuthLoginDefaultTUIModeRecommendedAlreadyGrantedSkipsTUIAndAuthorizationPage(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, t.TempDir()) t.Setenv("DWS_CONFIG_DIR", t.TempDir()) oldGuideSelector := authLoginGuideActionSelector oldGuideApplier := authLoginGuideActionApplier oldScopeSelector := loginRecommendScopeModeSelector oldProductSelector := loginRecommendProductSelector oldInteractiveTerminal := authLoginInteractiveTerminal t.Cleanup(func() { authLoginGuideActionSelector = oldGuideSelector authLoginGuideActionApplier = oldGuideApplier loginRecommendScopeModeSelector = oldScopeSelector loginRecommendProductSelector = oldProductSelector authLoginInteractiveTerminal = oldInteractiveTerminal }) authLoginInteractiveTerminal = func() bool { return true } authLoginGuideActionSelector = func() (authLoginGuideAction, error) { t.Fatal("default auth login must not call the operation guide selector") return "", nil } authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { t.Fatal("default auth login must not apply a post-login guide action") return nil } loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { t.Fatal("already-granted recommended auth must not call the scope-mode TUI") return "", nil } loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { t.Fatal("already-granted recommended auth must not call product-domain TUI") return nil, nil } fake := &authLoginRecommendSequenceCaller{responses: []string{ `{"success":true,"data":{"allGranted":false,"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":[]}}`, }} cmd := newAuthLoginCommand(fake) var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"--token", "login-token"}) if err := cmd.Execute(); err != nil { t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String()) } if len(fake.tools) != 1 { t.Fatalf("CallTool count = %d, want only preflight recommend plan", len(fake.tools)) } if fake.tools[0] != "pat.batch_plan" { t.Fatalf("tool sequence = %v, want only plan", fake.tools) } if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") { t.Fatalf("output = %q, want already-granted message", out.String()) } } func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, t.TempDir()) configDir := t.TempDir() t.Setenv("DWS_CONFIG_DIR", configDir) oldGuideSelector := authLoginGuideActionSelector oldGuideApplier := authLoginGuideActionApplier oldScopeSelector := loginRecommendScopeModeSelector oldProductSelector := loginRecommendProductSelector oldInteractiveTerminal := authLoginInteractiveTerminal t.Cleanup(func() { authLoginGuideActionSelector = oldGuideSelector authLoginGuideActionApplier = oldGuideApplier loginRecommendScopeModeSelector = oldScopeSelector loginRecommendProductSelector = oldProductSelector authLoginInteractiveTerminal = oldInteractiveTerminal }) authLoginInteractiveTerminal = func() bool { return true } var sawTokenBeforeScopeTUI bool var sawTokenBeforeProductTUI bool var sawTokenBeforePlan bool authLoginGuideActionSelector = func() (authLoginGuideAction, error) { t.Fatal("default login must not call the operation guide selector") return "", nil } authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { t.Fatal("default login must not apply a post-login guide action") return nil } loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { token, err := authpkg.LoadTokenData(configDir) if err != nil { t.Fatalf("LoadTokenData before scope TUI error = %v", err) } if token.AccessToken != "login-token" { t.Fatalf("AccessToken before scope TUI = %q, want login-token", token.AccessToken) } sawTokenBeforeScopeTUI = true return pat.LoginRecommendScopeAll, nil } loginRecommendProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { token, err := authpkg.LoadTokenData(configDir) if err != nil { t.Fatalf("LoadTokenData before product TUI error = %v", err) } if token.AccessToken != "login-token" { t.Fatalf("AccessToken before product TUI = %q, want login-token", token.AccessToken) } sawTokenBeforeProductTUI = true if len(products) != 1 || products[0].ProductCode != "calendar" { t.Fatalf("selector products = %+v, want calendar", products) } return []string{"calendar"}, nil } fake := &authLoginRecommendSequenceCaller{responses: []string{ `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, }, beforeCall: func(toolName string) { token, err := authpkg.LoadTokenData(configDir) if err != nil { t.Fatalf("LoadTokenData before %s error = %v", toolName, err) } if token.AccessToken != "login-token" { t.Fatalf("AccessToken before %s = %q, want login-token", toolName, token.AccessToken) } sawTokenBeforePlan = true }} cmd := newAuthLoginCommand(fake) var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"--token", "login-token"}) if err := cmd.Execute(); err != nil { t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String()) } if !sawTokenBeforeScopeTUI { t.Fatal("scope-mode TUI was not called after token save") } if !sawTokenBeforeProductTUI { t.Fatal("product-domain TUI was not called after token save") } if !sawTokenBeforePlan { t.Fatal("authorization plan was not called after token save") } if len(fake.tools) != 3 { t.Fatalf("CallTool count = %d, want discovery plan + selected plan + grant", len(fake.tools)) } if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_plan" || fake.tools[2] != "pat.batch_grant" { t.Fatalf("tool sequence = %v, want plan, plan, grant", fake.tools) } if got := fake.args[0]["recommend"]; got != true { t.Fatalf("discovery plan recommend = %#v, want true", got) } if got := fake.args[1]["recommend"]; got != false { t.Fatalf("selected all-scope plan recommend = %#v, want false", got) } if got := fake.args[1]["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) { t.Fatalf("selected all-scope plan productCodes = %#v, want calendar", got) } } func TestEnrichAuthLoginProfileFromContactBeforePersist(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, t.TempDir()) configDir := t.TempDir() t.Setenv("DWS_CONFIG_DIR", configDir) token := &authpkg.TokenData{ AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresAt: time.Now().Add(time.Hour), RefreshExpAt: time.Now().Add(24 * time.Hour), CorpID: "ding32fff839a3e0105d", ClientID: "client-id", Source: "mcp", } fake := &authLoginRecommendSequenceCaller{responses: []string{ `{"success":true,"result":[{"isAdmin":false,"orgEmployeeModel":{"jobNumber":"202397","orgId":null,"orgName":"钉钉(中国)信息技术有限公司","orgUserId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`, }} if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, fake, token); err != nil { t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err) } if token.CorpName != "钉钉(中国)信息技术有限公司" { t.Fatalf("token corpName = %q, want 钉钉(中国)信息技术有限公司", token.CorpName) } if token.UserID != "011352590165863362195" || token.UserName != "玄玦(主用钉)" { t.Fatalf("token user identity = (%q, %q), want contact result", token.UserID, token.UserName) } cfg, err := authpkg.LoadProfiles(configDir) if err != nil { t.Fatalf("LoadProfiles() before persist error = %v", err) } if len(cfg.Profiles) != 0 { t.Fatalf("identity enrichment persisted token early: %#v", cfg.Profiles) } if err := authpkg.SaveTokenData(configDir, token); err != nil { t.Fatalf("SaveTokenData() error = %v", err) } loaded, err := authpkg.LoadTokenDataForProfile(configDir, "ding32fff839a3e0105d") if err != nil { t.Fatalf("LoadTokenDataForProfile() error = %v", err) } if loaded.CorpName != "钉钉(中国)信息技术有限公司" { t.Fatalf("persisted corpName = %q, want 钉钉(中国)信息技术有限公司", loaded.CorpName) } if len(fake.tools) != 1 || fake.tools[0] != "get_current_user_profile" { t.Fatalf("tool calls = %v, want get_current_user_profile", fake.tools) } if len(fake.args[0]) != 0 { t.Fatalf("contact profile args = %#v, want no arguments", fake.args[0]) } if len(fake.tokens) != 1 || fake.tokens[0] != "access-token" { t.Fatalf("token overrides = %v, want access-token", fake.tokens) } } func TestEnrichAuthLoginProfileLogsIdentityResolutionWithoutCredentials(t *testing.T) { t.Setenv("DWS_DEBUG_AUTH", "1") var logs bytes.Buffer previousLogger := slog.Default() slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))) t.Cleanup(func() { slog.SetDefault(previousLogger) }) token := &authpkg.TokenData{ AccessToken: "secret-access-token", RefreshToken: "secret-refresh-token", CorpID: "ding_same_corp", } fake := &authLoginRecommendSequenceCaller{responses: []string{ `{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding_same_corp","orgName":"同一组织","userId":"user_two","orgUserName":"账号二"}}]}`, }} if err := enrichAuthLoginProfileFromContact(context.Background(), t.TempDir(), fake, token); err != nil { t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err) } got := logs.String() for _, want := range []string{ `"msg":"auth.login.identity.lookup.start"`, `"msg":"auth.login.identity.lookup.result"`, `"corp_id":"ding_same_corp"`, `"user_id":"user_two"`, `"user_name":"账号二"`, } { if !strings.Contains(got, want) { t.Fatalf("diagnostic logs missing %q:\n%s", want, got) } } for _, secret := range []string{"secret-access-token", "secret-refresh-token"} { if strings.Contains(got, secret) { t.Fatalf("diagnostic logs exposed credential %q:\n%s", secret, got) } } } type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } type authLoginRecommendSequenceCaller struct { responses []string tools []string args []map[string]any tokens []string beforeCall func(toolName string) } func (f *authLoginRecommendSequenceCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) { if f.beforeCall != nil { f.beforeCall(toolName) } f.tools = append(f.tools, toolName) copiedArgs := make(map[string]any, len(args)) for key, value := range args { copiedArgs[key] = value } f.args = append(f.args, copiedArgs) response := `{"success":true,"data":{}}` if len(f.responses) > 0 { response = f.responses[0] f.responses = f.responses[1:] } return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil } func (f *authLoginRecommendSequenceCaller) CallToolWithToken(ctx context.Context, token, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { f.tokens = append(f.tokens, token) return f.CallTool(ctx, productID, toolName, args) } func (f *authLoginRecommendSequenceCaller) Format() string { return "table" } func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false } func (f *authLoginRecommendSequenceCaller) Fields() string { return "" } func (f *authLoginRecommendSequenceCaller) JQ() string { return "" } func stringSliceArgEqual(got any, want []string) bool { if got == nil { return len(want) == 0 } switch values := got.(type) { case []string: if len(values) != len(want) { return false } for i := range values { if values[i] != want[i] { return false } } return true case []any: if len(values) != len(want) { return false } for i := range values { if values[i] != want[i] { return false } } return true default: return false } } func setupAuthLogoutProfiles(t *testing.T, tokens ...*authpkg.TokenData) string { t.Helper() root := t.TempDir() configDir := filepath.Join(root, "config") t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain")) t.Setenv("DWS_CONFIG_DIR", configDir) authpkg.SetRuntimeProfile("") ResetRuntimeTokenCache() clearCompatCache() t.Cleanup(func() { _ = authpkg.DeleteAllTokenData(configDir) authpkg.SetRuntimeProfile("") ResetRuntimeTokenCache() clearCompatCache() CloseFileLogger() }) for _, token := range tokens { if err := authpkg.SaveTokenData(configDir, token); err != nil { t.Fatalf("SaveTokenData(%s) error = %v", token.CorpID, err) } } return configDir } func authLogoutTestToken(corpID string) *authpkg.TokenData { return &authpkg.TokenData{ AccessToken: "access-" + corpID, RefreshToken: "refresh-" + corpID, ExpiresAt: time.Now().Add(time.Hour), RefreshExpAt: time.Now().Add(24 * time.Hour), CorpID: corpID, CorpName: corpID + " org", UserID: "user-" + corpID, UserName: "User " + corpID, ClientID: "client-" + corpID, } }