* 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>
583 lines
20 KiB
Go
583 lines
20 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"
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
|
||
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 != "corp_primary" || resp.CurrentProfile != "corp_secondary" || resp.PreviousProfile != "corp_primary" {
|
||
t.Fatalf("profile pointers = primary %q current %q previous %q, want corp_primary/corp_secondary/corp_primary", 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 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())
|
||
}
|
||
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" || cfg.PreviousProfile != "corp_secondary" {
|
||
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", 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())
|
||
}
|
||
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" || cfg.PreviousProfile != "corp_primary" {
|
||
t.Fatalf("profile pointers = current %q previous %q, want corp_secondary/corp_primary", 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" || cfg.PreviousProfile != "corp_secondary" {
|
||
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", 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())
|
||
}
|
||
cfg, err := authpkg.LoadProfiles(configDir)
|
||
if err != nil {
|
||
t.Fatalf("LoadProfiles() error = %v", err)
|
||
}
|
||
if cfg.CurrentProfile != "corp_primary" {
|
||
t.Fatalf("currentProfile = %q, want 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())
|
||
}
|
||
cfg, err = authpkg.LoadProfiles(configDir)
|
||
if err != nil {
|
||
t.Fatalf("LoadProfiles() error = %v", err)
|
||
}
|
||
if cfg.CurrentProfile != "corp_secondary" {
|
||
t.Fatalf("currentProfile = %q, want 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" {
|
||
t.Fatalf("currentProfile = %q, want 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 TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(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{"new", "fallback", "old"}
|
||
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" {
|
||
t.Fatalf("currentProfile = %q, want 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)
|
||
}
|
||
}
|