* feat(auth): support multi-profile login
* fix(auth): complete multi-org profile acceptance
* feat(auth): 完成多组织 profile 验收
* docs(auth): 补充多组织 Ralph 验收材料
* feat(auth): 支持 auth switch TUI 切换 profile
* feat(auth): logout 默认清理所有组织
* feat(auth): login 默认新增组织授权
* feat(profile): 使用 profile switch 切换组织
* docs(ralph): 更新 profile switch 验收材料
* fix(profile): 展示全部可切换组织
* feat(profile): support multi-org switch tui
* chore(install): add branch source installer
* fix(profile): keep global profile out of tool params
* feat(profile): support csv multi-profile runtime
* ci: add multi-profile e2e workflow
* ci: run multi-profile e2e on all branches
* docs: document multi-profile e2e ci gate
* docs: remove multi-profile test cases from pr
* ci: harden multi-profile e2e gates
* fix(auth): serialize profiles.json RMW and harden multi-profile persistence
Wrap all profiles.json read-modify-write paths (profile switch/use/remove,
status marking, token save, logout) in the existing dual-layer lock via a new
withProfilesLock helper. Split each writer into a public (locking) entry point
plus a lock-free *Locked variant so the non-reentrant lock is never re-acquired;
the refresh path (oauth_helpers) and the load-path legacy migration now call the
lock-free saver to avoid self-deadlock.
Also: write profiles.json and the token marker via per-write random temp names
(uuid) to stop concurrent writers from corrupting a fixed .tmp file; quarantine
an unparseable profiles.json and rebuild an empty config so the CLI can
self-heal instead of locking out auth reset/logout; make DeleteAllTokenData
proceed even if profiles.json cannot be read; and stop SyncLegacyTokenMirror
from deleting the legacy mirror on a transient keychain read error.
* fix(auth): do not fall back to a different org's legacy token slot
When no explicit --profile is given, LoadTokenDataForProfile resolves the
current/primary profile and reads its per-corp keychain slot. If that slot
read failed, the code silently fell through to the legacy single token slot,
which after any drift between the legacy mirror and the current profile could
belong to a different organization. The command would then run as the wrong
org with no indication to the user.
Reproduction (conceptual):
- profiles.json currentProfile = corpA
- corpA's keychain slot is unreadable, legacy single slot still holds corpB
- any read command (no --profile) silently used corpB's token
Fix: when a profile is resolved but its slot read fails and no --profile was
given, only fall back to the legacy single slot when its CorpID matches the
resolved profile (same org); otherwise return the original error instead of
acting as a different organization. The no-profile legacy path (pre-migration
installs with no resolved profile) is unchanged.
Tests:
- Covered by the existing internal/auth suite under go test -race; the
same-org fallback preserves the legacy-mirror case while the cross-org
case now surfaces the read error.
* feat(skill): document multi-org profile usage and always ship dws-shared
The skills had no guidance on the multi-profile capability, so an agent would
treat the CLI as single-org: when a lookup missed in the current org it would
give up or ask the user instead of searching other logged-in orgs. The multi
skill set also referenced a `dws-shared` prerequisite that was never actually
installed, and the only multi-org hints lived inline in three product skills.
This adds, in source only:
- A "multi-org / profile" section in the mono SKILL.md (concept, commands,
cross-org rule, aggregation, safety guardrails) plus a decision-tree entry,
trigger conditions, and a corrected logout danger-table row (logout removes
all orgs by default; removing the primary silently re-elects a new primary,
confirm before removing the primary).
- A standalone skills/multi/dingtalk-profile skill mirroring the same content.
- A new skills/multi/dws-shared skill that carries auth, global flags and the
multi-org rule, so every product skill's PREREQUISITE resolves and all
read/search skills inherit the cross-org behavior without per-skill edits.
- Cross-org fallback notes on dingtalk-aisearch / chat / contact.
To guarantee the prerequisite actually ships, multi-mode install now force-
includes dws-shared even when --skill / --exclude narrows the set (no-op when
the source has no dws-shared, preserving older layouts).
Tests:
- internal/app: TestP1SharedAlwaysIncludedWithSkillFilter installs with
`-s aitable` and asserts dws-shared still lands in the destination;
TestP1SharedNoopWhenAbsent guards the older-layout no-op.
- go test -race ./internal/auth/... ./internal/app/... passes.
---------
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: qinze <audanye@gmail.com>
894 lines
32 KiB
Go
894 lines
32 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"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"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/pkg/edition"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func TestAuthExportImportBase64RoundTrip(t *testing.T) {
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
|
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
|
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
|
t.Setenv("DWS_CONFIG_DIR", sourceConfig)
|
|
|
|
original := &authpkg.TokenData{
|
|
AccessToken: "access-cli",
|
|
RefreshToken: "refresh-cli",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
ClientID: "client-cli",
|
|
Source: "mcp",
|
|
}
|
|
if err := authpkg.SaveTokenData(sourceConfig, original); err != nil {
|
|
t.Fatalf("SaveTokenData() error = %v", err)
|
|
}
|
|
|
|
exportCmd := NewRootCommand()
|
|
var exported bytes.Buffer
|
|
exportCmd.SetOut(&exported)
|
|
exportCmd.SetErr(&bytes.Buffer{})
|
|
exportCmd.SetArgs([]string{"auth", "export", "--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)
|
|
}
|
|
|
|
targetKeychain := filepath.Join(targetRoot, "target-keychain")
|
|
targetConfig := filepath.Join(targetRoot, ".dws")
|
|
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
|
t.Setenv("DWS_CONFIG_DIR", targetConfig)
|
|
|
|
importCmd := NewRootCommand()
|
|
importCmd.SetOut(&bytes.Buffer{})
|
|
importCmd.SetErr(&bytes.Buffer{})
|
|
importCmd.SetArgs([]string{"auth", "import", "--input", inputPath, "--base64"})
|
|
if err := importCmd.Execute(); err != nil {
|
|
t.Fatalf("auth import --base64 error = %v", err)
|
|
}
|
|
|
|
loaded, err := authpkg.LoadTokenData(targetConfig)
|
|
if err != nil {
|
|
t.Fatalf("LoadTokenData() after CLI import error = %v", err)
|
|
}
|
|
if loaded.RefreshToken != original.RefreshToken {
|
|
t.Fatalf("refresh token = %q, want %q", loaded.RefreshToken, original.RefreshToken)
|
|
}
|
|
if !loaded.IsRefreshTokenValid() {
|
|
t.Fatal("refresh token should remain valid after CLI import")
|
|
}
|
|
}
|
|
|
|
func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
root := t.TempDir()
|
|
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 := NewRootCommand()
|
|
var stderr bytes.Buffer
|
|
importCmd.SetOut(&bytes.Buffer{})
|
|
importCmd.SetErr(&stderr)
|
|
importCmd.SetArgs([]string{"auth", "import", "--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 TestAuthStatusRefreshFailureLeavesStoredTokenIntact(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",
|
|
})
|
|
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{"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")
|
|
}
|
|
|
|
if !bytes.Contains(out.Bytes(), []byte("\"authenticated\"")) {
|
|
t.Fatalf("output should still report authenticated status:\n%s", out.String())
|
|
}
|
|
}
|
|
|
|
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" {
|
|
t.Fatalf("currentProfile = %q, want unchanged corp_secondary", cfg.CurrentProfile)
|
|
}
|
|
}
|
|
|
|
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 != "corp_secondary" || cfg.CurrentProfile != "corp_secondary" {
|
|
t.Fatalf("profiles pointers = primary %q current %q, want corp_secondary/corp_secondary", 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 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) {
|
|
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("force", false, "")
|
|
login.Flags().Bool("recommend", false, "")
|
|
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")
|
|
}
|
|
}
|
|
|
|
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"]}}`,
|
|
}}
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 TestEnrichAuthLoginProfileFromContactPersistsCorpName(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",
|
|
}
|
|
if err := authpkg.SaveTokenData(configDir, token); err != nil {
|
|
t.Fatalf("SaveTokenData() error = %v", err)
|
|
}
|
|
|
|
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
|
`{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding32fff839a3e0105d","orgName":"钉钉(中国)信息技术有限公司","userId":"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)
|
|
}
|
|
|
|
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 got := fake.args[0]["profile"]; got != "ding32fff839a3e0105d" {
|
|
t.Fatalf("contact profile arg = %#v, want ding32fff839a3e0105d", 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
|
|
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) Format() string { return "table" }
|
|
|
|
func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false }
|
|
|
|
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.SetRuntimeProfile("")
|
|
ResetRuntimeTokenCache()
|
|
clearCompatCache()
|
|
})
|
|
|
|
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,
|
|
}
|
|
}
|