Preserve manual-token defaults across explicit profile refreshes and selective logout while keeping legacy marker behavior compatible. Make profile login, refresh, switch, and logout writes rollback-safe; reject unsupported future profile versions before remote side effects; and prevent cross-profile token fallback. Forward token overrides through usage recording, use the newly authenticated identity for post-login authorization, distinguish unavailable profile state, and propagate Windows registry deletion failures.
769 lines
26 KiB
Go
769 lines
26 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"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
func TestWriteProfileUseJSONKeepsPrimaryAndCurrentDistinct(t *testing.T) {
|
||
profile := &authpkg.Profile{
|
||
Name: "B Org",
|
||
CorpID: "corp_b",
|
||
CorpName: "B Org",
|
||
Status: authpkg.ProfileStatusActive,
|
||
}
|
||
cfg := &authpkg.ProfilesConfig{
|
||
PrimaryProfile: "corp_a",
|
||
CurrentProfile: "corp_b",
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := writeProfileUseJSON(&buf, profile, cfg); err != nil {
|
||
t.Fatalf("writeProfileUseJSON() error = %v", err)
|
||
}
|
||
var resp profileUseResponse
|
||
if err := json.Unmarshal(buf.Bytes(), &resp); err != nil {
|
||
t.Fatalf("Unmarshal() error = %v", err)
|
||
}
|
||
if bytes.Contains(buf.Bytes(), []byte(`"name"`)) {
|
||
t.Fatalf("profile use JSON should not contain name when corpName is present:\n%s", buf.String())
|
||
}
|
||
if resp.Profile.CorpName != "B Org" {
|
||
t.Fatalf("corpName = %q, want B Org", resp.Profile.CorpName)
|
||
}
|
||
if !resp.Profile.IsCurrent {
|
||
t.Fatalf("isCurrent = false, want true")
|
||
}
|
||
if resp.Profile.IsPrimary {
|
||
t.Fatalf("isPrimary = true, want false")
|
||
}
|
||
}
|
||
|
||
func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) {
|
||
setupAuthLogoutProfiles(t,
|
||
authLogoutTestToken("corp_primary"),
|
||
authLogoutTestToken("corp_secondary"),
|
||
)
|
||
|
||
cmd := NewRootCommand()
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("profile list --format json error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
var resp profileListResponse
|
||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
if !resp.Success {
|
||
t.Fatal("success = false, want true")
|
||
}
|
||
if resp.PrimaryProfile != "" ||
|
||
resp.CurrentProfile != "corp_secondary:user-corp_secondary" ||
|
||
resp.PreviousProfile != "corp_primary:user-corp_primary" {
|
||
t.Fatalf("profile pointers = primary %q current %q previous %q", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile)
|
||
}
|
||
if len(resp.Profiles) != 2 {
|
||
t.Fatalf("profiles len = %d, want 2", len(resp.Profiles))
|
||
}
|
||
if bytes.Contains(out.Bytes(), []byte(`"name"`)) {
|
||
t.Fatalf("profile list JSON should not contain name when corpName is present:\n%s", out.String())
|
||
}
|
||
for _, p := range resp.Profiles {
|
||
if p.CorpName == "" {
|
||
t.Fatalf("profile %s missing corpName in JSON response: %#v", p.CorpID, p)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestProfileListRootCommandJSONIncludesAllAccountsInSameCorp(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 = "账号二"
|
||
setupAuthLogoutProfiles(t, first, second)
|
||
|
||
cmd := NewRootCommand()
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("profile list --format json error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
var resp profileListResponse
|
||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
if len(resp.Profiles) != 2 {
|
||
t.Fatalf("profiles len = %d, want 2: %#v", len(resp.Profiles), resp.Profiles)
|
||
}
|
||
got := make(map[string]profileView, len(resp.Profiles))
|
||
for _, profile := range resp.Profiles {
|
||
got[profile.Profile] = profile
|
||
}
|
||
if _, ok := got["corp_same:user_1"]; !ok {
|
||
t.Fatalf("profiles missing corp_same:user_1: %#v", resp.Profiles)
|
||
}
|
||
current, ok := got["corp_same:user_2"]
|
||
if !ok {
|
||
t.Fatalf("profiles missing corp_same:user_2: %#v", resp.Profiles)
|
||
}
|
||
if !current.IsOrgCurrent || !current.IsCurrent || current.IsPrimary {
|
||
t.Fatalf("last login account markers = %#v, want org-current/current and deprecated primary=false", current)
|
||
}
|
||
if got["corp_same:user_1"].IsOrgCurrent {
|
||
t.Fatalf("older account unexpectedly marked org current: %#v", got["corp_same:user_1"])
|
||
}
|
||
}
|
||
|
||
func TestProfileListUsesRealIdentityTokenState(t *testing.T) {
|
||
token := authLogoutTestToken("corp_real")
|
||
token.ExpiresAt = time.Date(2026, 7, 16, 17, 38, 0, 0, time.Local)
|
||
token.RefreshExpAt = time.Date(2026, 8, 16, 17, 38, 0, 0, time.Local)
|
||
configDir := setupAuthLogoutProfiles(t, token)
|
||
|
||
cfg, err := authpkg.LoadProfiles(configDir)
|
||
if err != nil {
|
||
t.Fatalf("LoadProfiles() error = %v", err)
|
||
}
|
||
cfg.Profiles[0].Status = authpkg.ProfileStatusActive
|
||
cfg.Profiles[0].ExpiresAt = "2026-07-16T22:29:00+08:00"
|
||
cfg.Profiles[0].RefreshExpAt = "2026-09-16T22:29:00+08:00"
|
||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||
t.Fatalf("SaveProfiles() error = %v", err)
|
||
}
|
||
|
||
cmd := NewRootCommand()
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("profile list error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
var resp profileListResponse
|
||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
if len(resp.Profiles) != 1 {
|
||
t.Fatalf("profiles len = %d, want 1", len(resp.Profiles))
|
||
}
|
||
got := resp.Profiles[0]
|
||
if got.ExpiresAt != token.ExpiresAt.Format(time.RFC3339) {
|
||
t.Fatalf("expiresAt = %q, want real token %q", got.ExpiresAt, token.ExpiresAt.Format(time.RFC3339))
|
||
}
|
||
if got.RefreshExpAt != token.RefreshExpAt.Format(time.RFC3339) {
|
||
t.Fatalf("refreshExpAt = %q, want real token %q", got.RefreshExpAt, token.RefreshExpAt.Format(time.RFC3339))
|
||
}
|
||
if got.Status != authpkg.ProfileStatusExpired {
|
||
t.Fatalf("status = %q, want expired", got.Status)
|
||
}
|
||
}
|
||
|
||
func TestProfileListDistinguishesMissingAndUnavailableTokenState(t *testing.T) {
|
||
originalLoad := profileLoadTokenData
|
||
t.Cleanup(func() { profileLoadTokenData = originalLoad })
|
||
profile := authpkg.Profile{CorpID: "corp", UserID: "user"}
|
||
|
||
profileLoadTokenData = func(string, string) (*authpkg.TokenData, error) {
|
||
return nil, authpkg.ErrTokenDataNotFound
|
||
}
|
||
if state := loadProfileTokenState("cfg", profile); state.Status != authpkg.ProfileStatusRevoked {
|
||
t.Fatalf("missing token status = %q, want revoked", state.Status)
|
||
}
|
||
|
||
profileLoadTokenData = func(string, string) (*authpkg.TokenData, error) {
|
||
return nil, errors.New("keychain unavailable")
|
||
}
|
||
if state := loadProfileTokenState("cfg", profile); state.Status != authpkg.ProfileStatusUnavailable {
|
||
t.Fatalf("unavailable token status = %q, want unavailable", state.Status)
|
||
}
|
||
}
|
||
|
||
func TestProfileListCurrentFlagsUseStoredExactSelectors(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)
|
||
}
|
||
cfg.PrimaryProfile = ""
|
||
cfg.CurrentProfile = "corp_same:user_1"
|
||
cfg.OrgCurrentProfiles["corp_same"] = "corp_same:user_1"
|
||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||
t.Fatalf("SaveProfiles() error = %v", err)
|
||
}
|
||
|
||
cmd := NewRootCommand()
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("profile list error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
var resp profileListResponse
|
||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
got := make(map[string]profileView, len(resp.Profiles))
|
||
for _, profile := range resp.Profiles {
|
||
got[profile.Profile] = profile
|
||
}
|
||
if !got["corp_same:user_1"].IsCurrent || !got["corp_same:user_1"].IsOrgCurrent {
|
||
t.Fatalf("first account flags = %#v, want current and org current", got["corp_same:user_1"])
|
||
}
|
||
if got["corp_same:user_2"].IsCurrent || got["corp_same:user_2"].IsOrgCurrent {
|
||
t.Fatalf("second account flags = %#v, want neither current nor org current", got["corp_same:user_2"])
|
||
}
|
||
if got["corp_same:user_1"].IsPrimary || got["corp_same:user_2"].IsPrimary {
|
||
t.Fatalf("deprecated isPrimary should be false without primaryProfile: %#v", got)
|
||
}
|
||
}
|
||
|
||
func TestProfileListTableOmitsDeprecatedPrimaryColumn(t *testing.T) {
|
||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_table"))
|
||
|
||
cmd := NewRootCommand()
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{"profile", "list"})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("profile list error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
header := strings.SplitN(out.String(), "\n", 2)[0]
|
||
if strings.Contains(header, "PRI") {
|
||
t.Fatalf("profile list header still contains deprecated PRI column: %q", header)
|
||
}
|
||
}
|
||
|
||
func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(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", "profile", "use", "corp_primary"})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("profile use corp_primary error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
CloseFileLogger()
|
||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||
t.Fatalf("profile use output should include organization name:\n%s", out.String())
|
||
}
|
||
cfg, err := authpkg.LoadProfiles(configDir)
|
||
if err != nil {
|
||
t.Fatalf("LoadProfiles() error = %v", err)
|
||
}
|
||
if cfg.CurrentProfile != "corp_primary:user-corp_primary" ||
|
||
cfg.PreviousProfile != "corp_secondary:user-corp_secondary" {
|
||
t.Fatalf("profile pointers = current %q previous %q", cfg.CurrentProfile, cfg.PreviousProfile)
|
||
}
|
||
legacyToken, err := authpkg.LoadTokenData(configDir)
|
||
if err != nil {
|
||
t.Fatalf("LoadTokenData() error = %v", err)
|
||
}
|
||
if legacyToken.CorpID != "corp_primary" {
|
||
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
|
||
}
|
||
|
||
cmd = NewRootCommand()
|
||
out.Reset()
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "-"})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("profile use - error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
CloseFileLogger()
|
||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_secondary org")) {
|
||
t.Fatalf("profile use - output should include organization name:\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" ||
|
||
cfg.PreviousProfile != "corp_primary:user-corp_primary" {
|
||
t.Fatalf("profile pointers = current %q previous %q", cfg.CurrentProfile, cfg.PreviousProfile)
|
||
}
|
||
legacyToken, err = authpkg.LoadTokenData(configDir)
|
||
if err != nil {
|
||
t.Fatalf("LoadTokenData() error = %v", err)
|
||
}
|
||
if legacyToken.CorpID != "corp_secondary" {
|
||
t.Fatalf("legacy token corp = %q, want corp_secondary", legacyToken.CorpID)
|
||
}
|
||
}
|
||
|
||
func TestProfileSwitchRootCommandSwitchesPrimaryOrganizationAndLegacyMirror(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", "profile", "switch", "corp_primary"})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("profile switch corp_primary error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||
t.Fatalf("profile switch output should include organization name:\n%s", out.String())
|
||
}
|
||
cfg, err := authpkg.LoadProfiles(configDir)
|
||
if err != nil {
|
||
t.Fatalf("LoadProfiles() error = %v", err)
|
||
}
|
||
if cfg.CurrentProfile != "corp_primary:user-corp_primary" ||
|
||
cfg.PreviousProfile != "corp_secondary:user-corp_secondary" {
|
||
t.Fatalf("profile pointers = current %q previous %q", cfg.CurrentProfile, cfg.PreviousProfile)
|
||
}
|
||
legacyToken, err := authpkg.LoadTokenData(configDir)
|
||
if err != nil {
|
||
t.Fatalf("LoadTokenData() error = %v", err)
|
||
}
|
||
if legacyToken.CorpID != "corp_primary" {
|
||
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
|
||
}
|
||
}
|
||
|
||
func TestProfileSwitchRootCommandSupportsCorpIDFlag(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", "profile", "switch", "--corpId", "corp_primary"})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("profile switch --corpId error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
CloseFileLogger()
|
||
cfg, err := authpkg.LoadProfiles(configDir)
|
||
if err != nil {
|
||
t.Fatalf("LoadProfiles() error = %v", err)
|
||
}
|
||
if cfg.CurrentProfile != "corp_primary:user-corp_primary" {
|
||
t.Fatalf("currentProfile = %q, want corp_primary:user-corp_primary", cfg.CurrentProfile)
|
||
}
|
||
|
||
cmd = NewRootCommand()
|
||
out.Reset()
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "--corp", "corp_secondary"})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("profile use --corp error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
CloseFileLogger()
|
||
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 corp_secondary:user-corp_secondary", cfg.CurrentProfile)
|
||
}
|
||
}
|
||
|
||
func TestProfileSwitchRootCommandRejectsConflictingSelectors(t *testing.T) {
|
||
setupAuthLogoutProfiles(t,
|
||
authLogoutTestToken("corp_primary"),
|
||
authLogoutTestToken("corp_secondary"),
|
||
)
|
||
|
||
cmd := NewRootCommand()
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{"profile", "switch", "corp_primary", "--corpId", "corp_secondary"})
|
||
err := cmd.Execute()
|
||
if err == nil {
|
||
t.Fatalf("profile switch with conflicting selectors succeeded\noutput:\n%s", out.String())
|
||
}
|
||
if !strings.Contains(err.Error(), "只能指定一个组织选择器") {
|
||
t.Fatalf("error = %v, want conflicting selector validation", err)
|
||
}
|
||
}
|
||
|
||
func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) {
|
||
configDir := setupAuthLogoutProfiles(t,
|
||
authLogoutTestToken("corp_primary"),
|
||
authLogoutTestToken("corp_secondary"),
|
||
)
|
||
oldSelector := profileSwitchSelector
|
||
t.Cleanup(func() {
|
||
profileSwitchSelector = oldSelector
|
||
})
|
||
called := false
|
||
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
|
||
called = true
|
||
if gotConfigDir != configDir {
|
||
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
|
||
}
|
||
return "corp_primary", nil
|
||
}
|
||
|
||
cmd := NewRootCommand()
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{"profile", "switch"})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("profile switch error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
if !called {
|
||
t.Fatal("profile switch without args did not invoke TUI selector")
|
||
}
|
||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||
t.Fatalf("profile switch TUI path should use human output by default:\n%s", out.String())
|
||
}
|
||
cfg, err := authpkg.LoadProfiles(configDir)
|
||
if err != nil {
|
||
t.Fatalf("LoadProfiles() error = %v", err)
|
||
}
|
||
if cfg.CurrentProfile != "corp_primary:user-corp_primary" {
|
||
t.Fatalf("currentProfile = %q, want corp_primary:user-corp_primary", cfg.CurrentProfile)
|
||
}
|
||
}
|
||
|
||
func TestProfileSwitchOptionLabelUsesOnlyOrganizationAndCurrentState(t *testing.T) {
|
||
cfg := &authpkg.ProfilesConfig{
|
||
PrimaryProfile: "corp_primary",
|
||
CurrentProfile: "corp_secondary",
|
||
Profiles: []authpkg.Profile{
|
||
{
|
||
CorpID: "corp_primary",
|
||
CorpName: "第一组织",
|
||
UserName: "alice",
|
||
Status: authpkg.ProfileStatusActive,
|
||
},
|
||
{
|
||
CorpID: "corp_secondary",
|
||
CorpName: "第二组织",
|
||
UserName: "bob",
|
||
Status: authpkg.ProfileStatusActive,
|
||
},
|
||
},
|
||
}
|
||
primary := profileSwitchOptionLabel(cfg.Profiles[0], cfg)
|
||
current := profileSwitchOptionLabel(cfg.Profiles[1], cfg)
|
||
for _, label := range []string{primary, current} {
|
||
if strings.Contains(label, "\n") {
|
||
t.Fatalf("profile switch label contains newline: %q", label)
|
||
}
|
||
}
|
||
if !strings.Contains(primary, "第一组织") {
|
||
t.Fatalf("primary option missing organization name: %q", primary)
|
||
}
|
||
if !strings.Contains(current, "当前组织") {
|
||
t.Fatalf("current option missing current marker: %q", current)
|
||
}
|
||
for _, unwanted := range []string{"alice", "bob", "已登录", "主组织", "corp_primary", "corp_secondary"} {
|
||
if strings.Contains(primary, unwanted) || strings.Contains(current, unwanted) {
|
||
t.Fatalf("profile switch option should not contain %q: %q / %q", unwanted, primary, current)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestProfileSwitchTUIViewUsesFixedOuterTable(t *testing.T) {
|
||
cfg := profileSwitchTestConfig(2)
|
||
model := newProfileSwitchTUIModel(cfg, "corp_00")
|
||
view := model.tableView()
|
||
if lines := strings.Split(view, "\n"); len(lines) != profileSwitchVisibleOptions+4 {
|
||
t.Fatalf("table line count = %d, want %d:\n%s", len(lines), profileSwitchVisibleOptions+4, view)
|
||
}
|
||
for _, want := range []string{"┌", "┬", "┐", "├", "┼", "┤", "└", "┴", "┘", "组织名", "本地状态"} {
|
||
if !strings.Contains(view, want) {
|
||
t.Fatalf("profile switch table missing %q in:\n%s", want, view)
|
||
}
|
||
}
|
||
for _, unwanted := range []string{"CORP_ID", "ORGANIZATION", "STATUS"} {
|
||
if strings.Contains(view, unwanted) {
|
||
t.Fatalf("profile switch table should not contain %q:\n%s", unwanted, view)
|
||
}
|
||
}
|
||
if got := strings.Count(view, "│"); got != (profileSwitchVisibleOptions+1)*3 {
|
||
t.Fatalf("table vertical separators = %d, want %d\n%s", got, (profileSwitchVisibleOptions+1)*3, view)
|
||
}
|
||
for _, profile := range cfg.Profiles {
|
||
if got := strings.Count(view, profile.CorpID); got != 0 {
|
||
t.Fatalf("profile corpId %s appears %d times, want hidden:\n%s", profile.CorpID, got, view)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestProfileSwitchTUIPreservesStoredOrderInsteadOfSortingByTime(t *testing.T) {
|
||
cfg := &authpkg.ProfilesConfig{
|
||
PrimaryProfile: "old",
|
||
CurrentProfile: "old",
|
||
Profiles: []authpkg.Profile{
|
||
{CorpID: "old", CorpName: "旧组织", LastLoginAt: "2026-06-26T10:00:00+08:00"},
|
||
{CorpID: "new", CorpName: "新组织", LastLoginAt: "2026-06-26T12:00:00+08:00"},
|
||
{CorpID: "fallback", CorpName: "兜底组织", UpdatedAt: "2026-06-26T11:00:00+08:00"},
|
||
},
|
||
}
|
||
model := newProfileSwitchTUIModel(cfg, "old")
|
||
gotOrder := []string{model.profiles[0].CorpID, model.profiles[1].CorpID, model.profiles[2].CorpID}
|
||
wantOrder := []string{"old", "new", "fallback"}
|
||
if strings.Join(gotOrder, ",") != strings.Join(wantOrder, ",") {
|
||
t.Fatalf("profile order = %v, want %v", gotOrder, wantOrder)
|
||
}
|
||
if got := model.selectedCorpID(); got != "old" {
|
||
t.Fatalf("selectedCorpID = %q, want old", got)
|
||
}
|
||
}
|
||
|
||
func TestProfileSwitchTUIArrowKeysMoveSelectionWithoutDuplicatingRows(t *testing.T) {
|
||
cfg := profileSwitchTestConfig(7)
|
||
model := newProfileSwitchTUIModel(cfg, "corp_00")
|
||
for step := 0; step < 6; step++ {
|
||
view := model.tableView()
|
||
if got := strings.Count(view, "›"); got != 1 {
|
||
t.Fatalf("step %d selected cursor count = %d, want 1:\n%s", step, got, view)
|
||
}
|
||
for _, profile := range cfg.Profiles {
|
||
name := profileOrgName(profile)
|
||
if got := strings.Count(view, name); got > 1 {
|
||
t.Fatalf("step %d profile %s appears %d times, want at most once:\n%s", step, name, got, view)
|
||
}
|
||
}
|
||
next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
|
||
model = next.(profileSwitchTUIModel)
|
||
}
|
||
if model.selected != 6 || model.offset != 2 {
|
||
t.Fatalf("selection after down keys = selected %d offset %d, want 6/2", model.selected, model.offset)
|
||
}
|
||
}
|
||
|
||
func TestProfileSwitchTableRowsKeepFixedDisplayWidth(t *testing.T) {
|
||
rows := []string{
|
||
profileSwitchTableLine("组织名", "本地状态"),
|
||
profileSwitchTableLine("› 钉钉(中国)信息技术有限公司", "当前组织"),
|
||
profileSwitchTableLine(" ACME", ""),
|
||
profileSwitchTableLine("", ""),
|
||
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
|
||
profileSwitchStyledTableLine("› 钉钉(中国)信息技术有限公司", "当前组织", profileSwitchSelectedRowStyle()),
|
||
profileSwitchStyledTableLine(" ACME", "", profileSwitchNormalRowStyle()),
|
||
profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()),
|
||
}
|
||
wantWidth := lipgloss.Width(rows[0])
|
||
for i, row := range rows {
|
||
if got := lipgloss.Width(row); got != wantWidth {
|
||
t.Fatalf("row[%d] width = %d, want %d: %q", i, got, wantWidth, row)
|
||
}
|
||
if got := strings.Count(row, "│"); got != 3 {
|
||
t.Fatalf("row[%d] separator count = %d, want 3: %q", i, got, row)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestProfileSwitchOptionLabelHidesCorpID(t *testing.T) {
|
||
const corpID = "ding8196cd9a2b2405da24f2f5cc6abecb85"
|
||
cfg := &authpkg.ProfilesConfig{
|
||
PrimaryProfile: corpID,
|
||
CurrentProfile: corpID,
|
||
}
|
||
label := profileSwitchOptionLabel(authpkg.Profile{
|
||
CorpID: corpID,
|
||
CorpName: "钉钉",
|
||
}, cfg)
|
||
for _, want := range []string{"钉钉", "当前组织"} {
|
||
if !strings.Contains(label, want) {
|
||
t.Fatalf("profile switch label missing %q in %q", want, label)
|
||
}
|
||
}
|
||
for _, unwanted := range []string{"ding8196", "cb85", "主组织"} {
|
||
if strings.Contains(label, unwanted) {
|
||
t.Fatalf("profile switch label should not contain %q in %q", unwanted, label)
|
||
}
|
||
}
|
||
}
|
||
|
||
func profileSwitchTestConfig(count int) *authpkg.ProfilesConfig {
|
||
cfg := &authpkg.ProfilesConfig{
|
||
PrimaryProfile: "corp_00",
|
||
CurrentProfile: "corp_00",
|
||
}
|
||
for i := 0; i < count; i++ {
|
||
corpID := fmt.Sprintf("corp_%02d", i)
|
||
cfg.Profiles = append(cfg.Profiles, authpkg.Profile{
|
||
CorpID: corpID,
|
||
CorpName: fmt.Sprintf("组织%02d", i),
|
||
Status: authpkg.ProfileStatusActive,
|
||
})
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
|
||
cmd := NewRootCommand()
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{"auth", "switch"})
|
||
err := cmd.Execute()
|
||
if err == nil {
|
||
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
|
||
}
|
||
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
|
||
t.Fatalf("error = %v, want auth switch unknown command", err)
|
||
}
|
||
}
|
||
|
||
func TestProfileUseNoArgsUsesTUISelector(t *testing.T) {
|
||
configDir := setupAuthLogoutProfiles(t,
|
||
authLogoutTestToken("corp_primary"),
|
||
authLogoutTestToken("corp_secondary"),
|
||
)
|
||
oldSelector := profileSwitchSelector
|
||
t.Cleanup(func() {
|
||
profileSwitchSelector = oldSelector
|
||
})
|
||
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
|
||
if gotConfigDir != configDir {
|
||
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
|
||
}
|
||
return "corp_primary", nil
|
||
}
|
||
|
||
cmd := NewRootCommand()
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{"profile", "use"})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("profile use error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||
t.Fatalf("profile use TUI path should use human output by default:\n%s", out.String())
|
||
}
|
||
cfg, err := authpkg.LoadProfiles(configDir)
|
||
if err != nil {
|
||
t.Fatalf("LoadProfiles() error = %v", err)
|
||
}
|
||
if cfg.CurrentProfile != "corp_primary:user-corp_primary" {
|
||
t.Fatalf("currentProfile = %q, want corp_primary:user-corp_primary", cfg.CurrentProfile)
|
||
}
|
||
}
|
||
|
||
func TestProfileSwitchSelectorRequiresInteractiveTerminal(t *testing.T) {
|
||
oldInteractive := profileSwitchInteractiveTerminal
|
||
t.Cleanup(func() {
|
||
profileSwitchInteractiveTerminal = oldInteractive
|
||
})
|
||
profileSwitchInteractiveTerminal = func() bool { return false }
|
||
|
||
_, err := selectProfileSwitchProfile(nil, t.TempDir())
|
||
if err == nil {
|
||
t.Fatal("selectProfileSwitchProfile() succeeded, want validation error")
|
||
}
|
||
if !bytes.Contains([]byte(err.Error()), []byte("profile selector required")) {
|
||
t.Fatalf("error = %v, want profile selector hint", err)
|
||
}
|
||
}
|
||
|
||
func TestWriteProfileListTableIncludesCorpName(t *testing.T) {
|
||
cfg := &authpkg.ProfilesConfig{
|
||
PrimaryProfile: "corp_a",
|
||
CurrentProfile: "corp_b",
|
||
Profiles: []authpkg.Profile{
|
||
{
|
||
Name: "DingTalk China",
|
||
CorpID: "corp_a",
|
||
CorpName: "钉钉(中国)信息技术有限公司",
|
||
UserName: "alice",
|
||
Status: authpkg.ProfileStatusActive,
|
||
},
|
||
{
|
||
Name: "B Org",
|
||
CorpID: "corp_b",
|
||
CorpName: "B 组织",
|
||
UserID: "bob-id",
|
||
},
|
||
},
|
||
}
|
||
var buf bytes.Buffer
|
||
writeProfileListTable(&buf, "", cfg)
|
||
out := buf.String()
|
||
for _, want := range []string{
|
||
"ORG_NAME",
|
||
"钉钉(中国)信息技术有限公司",
|
||
"B 组织",
|
||
"corp_a",
|
||
"corp_b",
|
||
} {
|
||
if !bytes.Contains(buf.Bytes(), []byte(want)) {
|
||
t.Fatalf("profile list table missing %q in output:\n%s", want, out)
|
||
}
|
||
}
|
||
for _, unwanted := range []string{"PROFILE", "DingTalk China"} {
|
||
if bytes.Contains(buf.Bytes(), []byte(unwanted)) {
|
||
t.Fatalf("profile list table should not contain %q in output:\n%s", unwanted, out)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestProfileUseMessageIncludesCorpName(t *testing.T) {
|
||
got := profileUseMessage(&authpkg.Profile{
|
||
Name: "DingTalk China",
|
||
CorpID: "ding8196",
|
||
CorpName: "钉钉(中国)信息技术有限公司",
|
||
})
|
||
for _, want := range []string{"当前组织: 钉钉(中国)信息技术有限公司", "ding8196"} {
|
||
if !bytes.Contains([]byte(got), []byte(want)) {
|
||
t.Fatalf("profileUseMessage() missing %q in %q", want, got)
|
||
}
|
||
}
|
||
if bytes.Contains([]byte(got), []byte("DingTalk China")) {
|
||
t.Fatalf("profileUseMessage() should not include profile name when corpName is present: %q", got)
|
||
}
|
||
}
|