Files
dingtalk-workspace-cli/internal/app/auth_command_test.go

2184 lines
77 KiB
Go

// 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,
}
}