Accept corpId:userId and friendly organization/account selectors across global --profile, profile switch/use, event child processes, and multi-profile command execution. List every local account in storage order with live identity-token status, preserve exact current and previous identities, and require explicit selection when an organization has no deterministic current account. Extend auth logout to remove one exact account, every account in one organization, or all local accounts while revoking each token with its persisted credentials. Use in-memory login tokens for identity enrichment before persistence, refresh generated Schema artifacts, and cover the complete CLI flow with isolated beta.3 end-to-end tests.
820 lines
31 KiB
Bash
Executable File
820 lines
31 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# End-to-end regression script for multi-profile / multi-organization login.
|
|
# It uses an isolated DWS_CONFIG_DIR and DWS_KEYCHAIN_DIR, seeds post-login
|
|
# token results through the production auth storage API, then verifies the real
|
|
# dws CLI command surface.
|
|
#
|
|
# Usage:
|
|
# bash scripts/dev/test-multi-profile-e2e.sh
|
|
# bash scripts/dev/test-multi-profile-e2e.sh --skip-go-tests --verbose
|
|
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
RUN_GO_TESTS=1
|
|
VERBOSE=0
|
|
KEEP_WORKDIR=0
|
|
E2E_VERSION="${DWS_PACKAGE_VERSION:-v1.0.53-beta.3}"
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--skip-go-tests)
|
|
RUN_GO_TESTS=0
|
|
shift
|
|
;;
|
|
--verbose)
|
|
VERBOSE=1
|
|
shift
|
|
;;
|
|
--keep-workdir)
|
|
KEEP_WORKDIR=1
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
sed -n '1,12p' "$0"
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "unknown option: $1" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
done
|
|
|
|
mkdir -p "$ROOT/.tmp-bin"
|
|
WORKDIR="$(mktemp -d "$ROOT/.tmp-bin/multi-profile-e2e.XXXXXX")"
|
|
BIN="$WORKDIR/bin/dws"
|
|
HELPER_DIR="$WORKDIR/helper"
|
|
CONFIG_DIR="$WORKDIR/config"
|
|
KEYCHAIN_DIR="$WORKDIR/keychain"
|
|
CACHE_DIR="$WORKDIR/cache"
|
|
OUT_DIR="$WORKDIR/out"
|
|
|
|
cleanup() {
|
|
if [[ "$KEEP_WORKDIR" -eq 1 ]]; then
|
|
echo "[INFO] kept workdir: $WORKDIR"
|
|
else
|
|
rm -rf "$WORKDIR"
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
export DWS_CONFIG_DIR="$CONFIG_DIR"
|
|
export DWS_KEYCHAIN_DIR="$KEYCHAIN_DIR"
|
|
export DWS_DISABLE_KEYCHAIN=1
|
|
export DWS_CACHE_DIR="$CACHE_DIR"
|
|
export DWS_PERF_REPORT=
|
|
export DWS_PERF_DEBUG=
|
|
|
|
mkdir -p "$HELPER_DIR" "$CONFIG_DIR" "$KEYCHAIN_DIR" "$CACHE_DIR" "$OUT_DIR" "$(dirname "$BIN")"
|
|
|
|
log() {
|
|
printf '\n==> %s\n' "$*"
|
|
}
|
|
|
|
fail() {
|
|
echo "[FAIL] $*" >&2
|
|
exit 1
|
|
}
|
|
|
|
run() {
|
|
if [[ "$VERBOSE" -eq 1 ]]; then
|
|
"$@"
|
|
else
|
|
"$@" >/dev/null
|
|
fi
|
|
}
|
|
|
|
capture() {
|
|
local file="$1"
|
|
shift
|
|
if [[ "$VERBOSE" -eq 1 ]]; then
|
|
echo "+ $*" >&2
|
|
fi
|
|
"$@" >"$file" 2>"$file.stderr"
|
|
}
|
|
|
|
expect_contains() {
|
|
local file="$1"
|
|
local needle="$2"
|
|
if ! grep -F -- "$needle" "$file" >/dev/null; then
|
|
echo "----- $file -----" >&2
|
|
cat "$file" >&2
|
|
fail "expected $file to contain: $needle"
|
|
fi
|
|
}
|
|
|
|
expect_not_contains() {
|
|
local file="$1"
|
|
local needle="$2"
|
|
if grep -F -- "$needle" "$file" >/dev/null; then
|
|
echo "----- $file -----" >&2
|
|
cat "$file" >&2
|
|
fail "did not expect $file to contain: $needle"
|
|
fi
|
|
}
|
|
|
|
expect_not_contains_line_command() {
|
|
local file="$1"
|
|
local command="$2"
|
|
if grep -E "^[[:space:]]+$command([[:space:]]|$)" "$file" >/dev/null; then
|
|
echo "----- $file -----" >&2
|
|
cat "$file" >&2
|
|
fail "did not expect command '$command' in $file"
|
|
fi
|
|
}
|
|
|
|
expect_fail() {
|
|
local needle="$1"
|
|
shift
|
|
local output
|
|
set +e
|
|
output="$("$@" 2>&1)"
|
|
local code=$?
|
|
set -e
|
|
if [[ "$code" -eq 0 ]]; then
|
|
echo "$output" >&2
|
|
fail "expected command to fail: $*"
|
|
fi
|
|
if ! grep -F -- "$needle" <<<"$output" >/dev/null; then
|
|
echo "$output" >&2
|
|
fail "expected failure output to contain: $needle"
|
|
fi
|
|
}
|
|
|
|
cat >"$HELPER_DIR/main.go" <<'GOEOF'
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
auth "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
|
)
|
|
|
|
type profileListResponse struct {
|
|
Success bool `json:"success"`
|
|
PrimaryProfile string `json:"primaryProfile"`
|
|
CurrentProfile string `json:"currentProfile"`
|
|
PreviousProfile string `json:"previousProfile"`
|
|
Profiles []profileView `json:"profiles"`
|
|
}
|
|
|
|
type profileUseResponse struct {
|
|
Success bool `json:"success"`
|
|
Profile profileView `json:"profile"`
|
|
}
|
|
|
|
type profileView struct {
|
|
Profile string `json:"profile"`
|
|
CorpID string `json:"corpId"`
|
|
CorpName string `json:"corpName"`
|
|
UserID string `json:"userId"`
|
|
UserName string `json:"userName"`
|
|
Status string `json:"status"`
|
|
ExpiresAt string `json:"expiresAt"`
|
|
RefreshExpAt string `json:"refreshExpAt"`
|
|
IsPrimary bool `json:"isPrimary"`
|
|
IsCurrent bool `json:"isCurrent"`
|
|
IsOrgCurrent bool `json:"isOrgCurrent"`
|
|
}
|
|
|
|
type authStatusResponse struct {
|
|
Success bool `json:"success"`
|
|
Authenticated bool `json:"authenticated"`
|
|
TokenValid bool `json:"token_valid"`
|
|
RefreshTokenValid bool `json:"refresh_token_valid"`
|
|
CorpID string `json:"corp_id"`
|
|
CorpName string `json:"corp_name"`
|
|
UserID string `json:"user_id"`
|
|
UserName string `json:"user_name"`
|
|
}
|
|
|
|
type multiProfileResponse struct {
|
|
Success bool `json:"success"`
|
|
MultiProfile bool `json:"multiProfile"`
|
|
Summary multiProfileSummary `json:"summary"`
|
|
Profiles []multiProfileResult `json:"profiles"`
|
|
}
|
|
|
|
type multiProfileSummary struct {
|
|
Total int `json:"total"`
|
|
Succeeded int `json:"succeeded"`
|
|
Failed int `json:"failed"`
|
|
}
|
|
|
|
type multiProfileResult struct {
|
|
Selector string `json:"selector"`
|
|
Profile string `json:"profile"`
|
|
CorpID string `json:"corpId"`
|
|
CorpName string `json:"corpName"`
|
|
OK bool `json:"ok"`
|
|
Result map[string]any `json:"result"`
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
die("missing helper command")
|
|
}
|
|
configDir := os.Getenv("DWS_CONFIG_DIR")
|
|
if strings.TrimSpace(configDir) == "" {
|
|
die("DWS_CONFIG_DIR is required")
|
|
}
|
|
switch os.Args[1] {
|
|
case "seed":
|
|
needArgs(7)
|
|
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
|
|
must(auth.SaveTokenData(configDir, data))
|
|
case "seed-orphan":
|
|
needArgs(7)
|
|
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
|
|
must(auth.SaveTokenDataKeychainForIdentity(data.CorpID, data.UserID, data))
|
|
case "seed-expired":
|
|
needArgs(7)
|
|
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
|
|
data.ExpiresAt = time.Now().Add(-time.Hour)
|
|
must(auth.SaveTokenData(configDir, data))
|
|
case "seed-legacy":
|
|
needArgs(7)
|
|
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
|
|
must(auth.SaveTokenDataKeychain(data))
|
|
must(auth.WriteTokenMarker(configDir))
|
|
case "write-app-config":
|
|
needArgs(4)
|
|
must(auth.SaveAppConfig(configDir, &auth.AppConfig{
|
|
ClientID: os.Args[2],
|
|
ClientSecret: auth.PlainSecret(os.Args[3]),
|
|
}))
|
|
case "assert-app-config":
|
|
needArgs(3)
|
|
cfg, err := auth.LoadAppConfig(configDir)
|
|
must(err)
|
|
switch os.Args[2] {
|
|
case "exists":
|
|
if cfg == nil || strings.TrimSpace(cfg.ClientID) == "" {
|
|
die("expected app config to exist")
|
|
}
|
|
case "absent":
|
|
if cfg != nil {
|
|
die("expected app config to be absent, got clientID=%q", cfg.ClientID)
|
|
}
|
|
default:
|
|
die("unknown app config expectation %q", os.Args[2])
|
|
}
|
|
case "assert-profiles":
|
|
needArgs(6)
|
|
cfg, err := auth.LoadProfiles(configDir)
|
|
must(err)
|
|
wantCount := atoi(os.Args[2])
|
|
if len(cfg.Profiles) != wantCount {
|
|
die("profiles len=%d, want %d: %#v", len(cfg.Profiles), wantCount, cfg.Profiles)
|
|
}
|
|
if wantCount > 0 && cfg.Version != 2 {
|
|
die("profiles version=%d, want 2", cfg.Version)
|
|
}
|
|
assertEqual("primaryProfile", cfg.PrimaryProfile, emptySentinel(os.Args[3]))
|
|
assertEqual("currentProfile", cfg.CurrentProfile, emptySentinel(os.Args[4]))
|
|
assertEqual("previousProfile", cfg.PreviousProfile, emptySentinel(os.Args[5]))
|
|
assertNoSecrets(configDir)
|
|
assertProfileMetadata(cfg)
|
|
case "assert-org-current":
|
|
needArgs(4)
|
|
cfg, err := auth.LoadProfiles(configDir)
|
|
must(err)
|
|
assertEqual("orgCurrentProfiles["+os.Args[2]+"]", cfg.OrgCurrentProfiles[os.Args[2]], emptySentinel(os.Args[3]))
|
|
case "assert-list-json":
|
|
needArgs(7)
|
|
var resp profileListResponse
|
|
raw := readJSON(os.Args[2], &resp)
|
|
if strings.Contains(string(raw), `"name"`) {
|
|
die("profile list JSON must not expose local name: %s", string(raw))
|
|
}
|
|
if !resp.Success {
|
|
die("profile list success=false")
|
|
}
|
|
wantCount := atoi(os.Args[3])
|
|
if len(resp.Profiles) != wantCount {
|
|
die("list profiles len=%d, want %d: %#v", len(resp.Profiles), wantCount, resp.Profiles)
|
|
}
|
|
assertEqual("list primaryProfile", resp.PrimaryProfile, emptySentinel(os.Args[4]))
|
|
assertEqual("list currentProfile", resp.CurrentProfile, emptySentinel(os.Args[5]))
|
|
assertEqual("list previousProfile", resp.PreviousProfile, emptySentinel(os.Args[6]))
|
|
primaryCount := 0
|
|
currentCount := 0
|
|
for _, p := range resp.Profiles {
|
|
if strings.TrimSpace(p.CorpID) == "" || strings.TrimSpace(p.CorpName) == "" {
|
|
die("profile list item missing corp identity: %#v", p)
|
|
}
|
|
if strings.TrimSpace(p.UserID) != "" && p.Profile != p.CorpID+":"+p.UserID {
|
|
die("profile selector=%q, want %s:%s", p.Profile, p.CorpID, p.UserID)
|
|
}
|
|
if p.IsPrimary {
|
|
primaryCount++
|
|
}
|
|
if p.IsCurrent {
|
|
currentCount++
|
|
}
|
|
}
|
|
if resp.PrimaryProfile != "" && primaryCount != 1 {
|
|
die("primary marker count=%d, want 1", primaryCount)
|
|
}
|
|
if resp.CurrentProfile != "" && currentCount != 1 {
|
|
die("current marker count=%d, want 1", currentCount)
|
|
}
|
|
orgCurrentCounts := map[string]int{}
|
|
for _, p := range resp.Profiles {
|
|
if p.IsOrgCurrent {
|
|
orgCurrentCounts[p.CorpID]++
|
|
}
|
|
}
|
|
for corpID, count := range orgCurrentCounts {
|
|
if count != 1 {
|
|
die("organization %s has %d org-current accounts, want 1", corpID, count)
|
|
}
|
|
}
|
|
case "assert-list-account":
|
|
needArgs(7)
|
|
var resp profileListResponse
|
|
readJSON(os.Args[2], &resp)
|
|
for _, p := range resp.Profiles {
|
|
if p.Profile != os.Args[3] {
|
|
continue
|
|
}
|
|
assertEqual("list account status", p.Status, os.Args[4])
|
|
assertEqual("list account isCurrent", fmt.Sprint(p.IsCurrent), os.Args[5])
|
|
assertEqual("list account isOrgCurrent", fmt.Sprint(p.IsOrgCurrent), os.Args[6])
|
|
if strings.TrimSpace(p.ExpiresAt) == "" || strings.TrimSpace(p.RefreshExpAt) == "" {
|
|
die("list account missing live token expiry: %#v", p)
|
|
}
|
|
return
|
|
}
|
|
die("list account %q not found", os.Args[3])
|
|
case "assert-list-order":
|
|
needArgs(4)
|
|
var resp profileListResponse
|
|
readJSON(os.Args[2], &resp)
|
|
want := strings.Split(os.Args[3], ",")
|
|
if len(resp.Profiles) != len(want) {
|
|
die("list order profile count=%d, want %d", len(resp.Profiles), len(want))
|
|
}
|
|
for i := range want {
|
|
assertEqual(fmt.Sprintf("list order[%d]", i), resp.Profiles[i].Profile, strings.TrimSpace(want[i]))
|
|
}
|
|
case "assert-switch-json":
|
|
needArgs(5)
|
|
var resp profileUseResponse
|
|
readJSON(os.Args[2], &resp)
|
|
if !resp.Success {
|
|
die("switch JSON success=false")
|
|
}
|
|
assertEqual("switch corpId", resp.Profile.CorpID, os.Args[3])
|
|
assertEqual("switch corpName", resp.Profile.CorpName, os.Args[4])
|
|
if !resp.Profile.IsCurrent {
|
|
die("switch profile isCurrent=false")
|
|
}
|
|
case "assert-status-json":
|
|
needArgs(6)
|
|
var resp authStatusResponse
|
|
readJSON(os.Args[2], &resp)
|
|
if !resp.Success || !resp.Authenticated || !resp.TokenValid || !resp.RefreshTokenValid {
|
|
die("bad auth status response: %#v", resp)
|
|
}
|
|
assertEqual("status corpId", resp.CorpID, os.Args[3])
|
|
assertEqual("status corpName", resp.CorpName, os.Args[4])
|
|
assertEqual("status userId", resp.UserID, os.Args[5])
|
|
case "assert-multi-profile-json":
|
|
needArgs(5)
|
|
var resp multiProfileResponse
|
|
readJSON(os.Args[2], &resp)
|
|
if !resp.Success || !resp.MultiProfile {
|
|
die("bad multi-profile response: %#v", resp)
|
|
}
|
|
wantCount := atoi(os.Args[3])
|
|
if len(resp.Profiles) != wantCount {
|
|
die("multi-profile len=%d, want %d: %#v", len(resp.Profiles), wantCount, resp.Profiles)
|
|
}
|
|
if resp.Summary.Total != wantCount || resp.Summary.Succeeded != wantCount || resp.Summary.Failed != 0 {
|
|
die("bad multi-profile summary: %#v", resp.Summary)
|
|
}
|
|
wantCorpIDs := strings.Split(os.Args[4], ",")
|
|
if len(wantCorpIDs) != wantCount {
|
|
die("want corpId count=%d, want %d", len(wantCorpIDs), wantCount)
|
|
}
|
|
for i, want := range wantCorpIDs {
|
|
want = strings.TrimSpace(want)
|
|
got := resp.Profiles[i]
|
|
if !got.OK {
|
|
die("profile %d ok=false: %#v", i, got)
|
|
}
|
|
assertEqual(fmt.Sprintf("multi-profile corpId[%d]", i), got.CorpID, want)
|
|
if got.Result["_mock"] != true {
|
|
die("profile %s result is not mock payload: %#v", got.CorpID, got.Result)
|
|
}
|
|
}
|
|
case "assert-token":
|
|
needArgs(5)
|
|
data, err := loadToken(configDir, os.Args[2])
|
|
must(err)
|
|
assertEqual("token corpId", data.CorpID, os.Args[3])
|
|
assertEqual("token access", data.AccessToken, os.Args[4])
|
|
case "assert-token-missing":
|
|
needArgs(3)
|
|
if data, err := loadToken(configDir, os.Args[2]); err == nil {
|
|
die("token %q still exists: %#v", os.Args[2], data)
|
|
}
|
|
case "delete":
|
|
needArgs(3)
|
|
must(auth.DeleteTokenDataForProfile(configDir, os.Args[2]))
|
|
case "assert-empty-auth":
|
|
needArgs(2)
|
|
cfg, err := auth.LoadProfiles(configDir)
|
|
must(err)
|
|
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 {
|
|
die("expected empty profiles after reset, got %#v", cfg)
|
|
}
|
|
if auth.TokenDataExistsKeychain() {
|
|
die("legacy auth-token still exists")
|
|
}
|
|
case "assert-duplicate-name-fallback":
|
|
needArgs(4)
|
|
cfg, err := auth.LoadProfiles(configDir)
|
|
must(err)
|
|
p := findProfile(cfg, os.Args[2])
|
|
if p == nil {
|
|
die("profile %q not found", os.Args[2])
|
|
}
|
|
if p.CorpName != os.Args[3] {
|
|
die("profile %s corpName=%q, want %q", p.CorpID, p.CorpName, os.Args[3])
|
|
}
|
|
if p.Name == os.Args[3] || !strings.HasPrefix(p.Name, os.Args[3]+"-") {
|
|
die("profile %s name=%q, want stable fallback prefix %q", p.CorpID, p.Name, os.Args[3]+"-")
|
|
}
|
|
default:
|
|
die("unknown helper command %q", os.Args[1])
|
|
}
|
|
}
|
|
|
|
func token(corpID, corpName, userID, userName, access string) *auth.TokenData {
|
|
return &auth.TokenData{
|
|
AccessToken: access,
|
|
RefreshToken: "refresh-" + corpID,
|
|
PersistentCode: "persistent-" + corpID,
|
|
ExpiresAt: time.Now().Add(2 * time.Hour),
|
|
RefreshExpAt: time.Now().Add(720 * time.Hour),
|
|
CorpID: corpID,
|
|
CorpName: corpName,
|
|
UserID: userID,
|
|
UserName: userName,
|
|
ClientID: "client-" + corpID,
|
|
Source: "multi-profile-e2e",
|
|
}
|
|
}
|
|
|
|
func needArgs(n int) {
|
|
if len(os.Args) != n {
|
|
die("%s: got %d args, want %d", os.Args[1], len(os.Args)-2, n-2)
|
|
}
|
|
}
|
|
|
|
func loadToken(configDir, selector string) (*auth.TokenData, error) {
|
|
if selector == "default" {
|
|
return auth.LoadTokenData(configDir)
|
|
}
|
|
return auth.LoadTokenDataForProfile(configDir, selector)
|
|
}
|
|
|
|
func readJSON(path string, dst any) []byte {
|
|
data, err := os.ReadFile(path)
|
|
must(err)
|
|
if err := json.Unmarshal(data, dst); err != nil {
|
|
die("parse %s: %v\n%s", path, err, string(data))
|
|
}
|
|
return data
|
|
}
|
|
|
|
func assertProfileMetadata(cfg *auth.ProfilesConfig) {
|
|
names := map[string]string{}
|
|
for _, p := range cfg.Profiles {
|
|
if strings.TrimSpace(p.CorpID) == "" || strings.TrimSpace(p.CorpName) == "" {
|
|
die("profile missing corp metadata: %#v", p)
|
|
}
|
|
if prev, ok := names[p.Name]; ok {
|
|
die("duplicate profile local name %q for %s and %s", p.Name, prev, p.CorpID)
|
|
}
|
|
if p.ExpiresAt != "" || p.RefreshExpAt != "" || p.LastLoginAt != "" || p.LastUsedAt != "" || p.UpdatedAt != "" {
|
|
die("new profile must not persist derived token times: %#v", p)
|
|
}
|
|
names[p.Name] = p.CorpID
|
|
}
|
|
}
|
|
|
|
func assertNoSecrets(configDir string) {
|
|
data, err := os.ReadFile(filepath.Join(configDir, "profiles.json"))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return
|
|
}
|
|
must(err)
|
|
}
|
|
for _, forbidden := range []string{"access_token", "refresh_token", "persistent_code", "client_secret"} {
|
|
if strings.Contains(string(data), forbidden) {
|
|
die("profiles.json contains secret field %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func findProfile(cfg *auth.ProfilesConfig, corpID string) *auth.Profile {
|
|
for i := range cfg.Profiles {
|
|
if cfg.Profiles[i].CorpID == corpID {
|
|
return &cfg.Profiles[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func atoi(raw string) int {
|
|
var n int
|
|
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil {
|
|
die("invalid integer %q", raw)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func emptySentinel(s string) string {
|
|
if s == "_" {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
func assertEqual(label, got, want string) {
|
|
if got != want {
|
|
die("%s=%q, want %q", label, got, want)
|
|
}
|
|
}
|
|
|
|
func must(err error) {
|
|
if err != nil {
|
|
die("%v", err)
|
|
}
|
|
}
|
|
|
|
func die(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
|
os.Exit(1)
|
|
}
|
|
GOEOF
|
|
|
|
cd "$ROOT"
|
|
|
|
if [[ "$RUN_GO_TESTS" -eq 1 ]]; then
|
|
log "running multi-profile Go regressions"
|
|
go test -timeout 180s -count=1 ./internal/auth ./internal/app ./test/cli
|
|
fi
|
|
|
|
log "building dws"
|
|
run go build -ldflags="-X github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app.version=$E2E_VERSION" -o "$BIN" ./cmd
|
|
|
|
helper() {
|
|
go run "$HELPER_DIR" "$@"
|
|
}
|
|
|
|
log "checking command surface"
|
|
capture "$OUT_DIR/version.txt" "$BIN" version
|
|
expect_contains "$OUT_DIR/version.txt" "$E2E_VERSION"
|
|
capture "$OUT_DIR/root-help.txt" "$BIN" --help
|
|
expect_contains "$OUT_DIR/root-help.txt" "--profile"
|
|
expect_contains "$OUT_DIR/root-help.txt" "--yes"
|
|
expect_contains "$OUT_DIR/root-help.txt" "--dry-run"
|
|
expect_contains "$OUT_DIR/root-help.txt" "profile"
|
|
capture "$OUT_DIR/profile-help.txt" "$BIN" profile --help
|
|
expect_contains "$OUT_DIR/profile-help.txt" "list"
|
|
expect_contains "$OUT_DIR/profile-help.txt" "switch"
|
|
expect_contains "$OUT_DIR/profile-help.txt" "use"
|
|
expect_contains "$OUT_DIR/profile-help.txt" "--profile"
|
|
capture "$OUT_DIR/auth-login-help.txt" "$BIN" auth login --help
|
|
expect_contains "$OUT_DIR/auth-login-help.txt" "--device"
|
|
expect_contains "$OUT_DIR/auth-login-help.txt" "--token"
|
|
expect_contains "$OUT_DIR/auth-login-help.txt" "--recommend"
|
|
expect_contains "$OUT_DIR/auth-login-help.txt" "--yes"
|
|
capture "$OUT_DIR/skill-setup-help.txt" "$BIN" skill setup --help
|
|
expect_contains "$OUT_DIR/skill-setup-help.txt" "--mode"
|
|
expect_contains "$OUT_DIR/skill-setup-help.txt" "--target"
|
|
expect_contains "$OUT_DIR/skill-setup-help.txt" "--yes"
|
|
expect_contains "$OUT_DIR/skill-setup-help.txt" "--skill"
|
|
expect_contains "$OUT_DIR/skill-setup-help.txt" "--exclude"
|
|
capture "$OUT_DIR/upgrade-help.txt" "$BIN" upgrade --help
|
|
expect_contains "$OUT_DIR/upgrade-help.txt" "--dry-run"
|
|
expect_contains "$OUT_DIR/upgrade-help.txt" "--yes"
|
|
capture "$OUT_DIR/dev-connect-help.txt" "$BIN" dev connect --help
|
|
expect_contains "$OUT_DIR/dev-connect-help.txt" "--robot-client-id"
|
|
expect_contains "$OUT_DIR/dev-connect-help.txt" "--robot-client-secret"
|
|
expect_contains "$OUT_DIR/dev-connect-help.txt" "--unified-app-id"
|
|
expect_contains "$OUT_DIR/dev-connect-help.txt" "--agent-cmd"
|
|
expect_contains "$OUT_DIR/dev-connect-help.txt" "--daemon"
|
|
capture "$OUT_DIR/doc-delete-help.txt" "$BIN" doc delete --help
|
|
expect_contains "$OUT_DIR/doc-delete-help.txt" "--yes"
|
|
capture "$OUT_DIR/aitable-base-delete-help.txt" "$BIN" aitable base delete --help
|
|
expect_contains "$OUT_DIR/aitable-base-delete-help.txt" "--yes"
|
|
capture "$OUT_DIR/auth-help.txt" "$BIN" auth --help
|
|
expect_not_contains_line_command "$OUT_DIR/auth-help.txt" "switch"
|
|
|
|
log "checking embedded mono and multi skills"
|
|
MONO_HOME="$WORKDIR/home-mono"
|
|
MULTI_HOME="$WORKDIR/home-multi"
|
|
mkdir -p "$MONO_HOME" "$MULTI_HOME"
|
|
capture "$OUT_DIR/skill-mono-setup.txt" env HOME="$MONO_HOME" "$BIN" skill setup --mode mono --target codex --source "$ROOT" --yes
|
|
expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "corpId:userId"
|
|
expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
|
|
expect_contains "$MONO_HOME/.codex/skills/dws/references/global-reference.md" "userId/userName"
|
|
capture "$OUT_DIR/skill-multi-setup.txt" env HOME="$MULTI_HOME" "$BIN" skill setup --mode multi --target codex --source "$ROOT" --yes
|
|
expect_contains "$MULTI_HOME/.codex/skills/dws-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
|
|
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-profile/SKILL.md" "corpId:userId"
|
|
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-profile/SKILL.md" "isOrgCurrent"
|
|
|
|
log "verifying empty profile list"
|
|
capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json
|
|
helper assert-list-json "$OUT_DIR/list-empty.json" 0 _ _ _
|
|
|
|
log "seeding first organization profile"
|
|
helper seed corp_alpha "Alpha Org" user_alpha "Alice Alpha" access-alpha-v1
|
|
capture "$OUT_DIR/list-alpha.json" "$BIN" profile list --format json
|
|
helper assert-list-json "$OUT_DIR/list-alpha.json" 1 _ corp_alpha:user_alpha _
|
|
helper assert-list-account "$OUT_DIR/list-alpha.json" corp_alpha:user_alpha active true true
|
|
helper assert-profiles 1 _ corp_alpha:user_alpha _
|
|
helper assert-org-current corp_alpha corp_alpha:user_alpha
|
|
helper assert-token default corp_alpha access-alpha-v1
|
|
helper assert-token corp_alpha corp_alpha access-alpha-v1
|
|
capture "$OUT_DIR/status-alpha-default.json" "$BIN" auth status --format json
|
|
helper assert-status-json "$OUT_DIR/status-alpha-default.json" corp_alpha "Alpha Org" user_alpha
|
|
|
|
log "seeding second organization profile"
|
|
helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v1
|
|
capture "$OUT_DIR/list-alpha-beta.json" "$BIN" profile list --format json
|
|
helper assert-list-json "$OUT_DIR/list-alpha-beta.json" 2 _ corp_beta:user_beta corp_alpha:user_alpha
|
|
helper assert-profiles 2 _ corp_beta:user_beta corp_alpha:user_alpha
|
|
helper assert-org-current corp_alpha corp_alpha:user_alpha
|
|
helper assert-org-current corp_beta corp_beta:user_beta
|
|
helper assert-token default corp_beta access-beta-v1
|
|
helper assert-token corp_alpha corp_alpha access-alpha-v1
|
|
helper assert-token corp_beta corp_beta access-beta-v1
|
|
|
|
log "refreshing existing organization without duplicating profile"
|
|
helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v2
|
|
capture "$OUT_DIR/list-beta-refresh.json" "$BIN" profile list --format json
|
|
helper assert-list-json "$OUT_DIR/list-beta-refresh.json" 2 _ corp_beta:user_beta corp_alpha:user_alpha
|
|
helper assert-profiles 2 _ corp_beta:user_beta corp_alpha:user_alpha
|
|
helper assert-token corp_beta corp_beta access-beta-v2
|
|
|
|
log "seeding duplicate organization name and checking stable fallback"
|
|
helper seed corp_gamma "Beta Org" user_gamma "Gina Gamma" access-gamma-v1
|
|
capture "$OUT_DIR/list-duplicate-name.json" "$BIN" profile list --format json
|
|
helper assert-list-json "$OUT_DIR/list-duplicate-name.json" 3 _ corp_gamma:user_gamma corp_beta:user_beta
|
|
helper assert-profiles 3 _ corp_gamma:user_gamma corp_beta:user_beta
|
|
helper assert-org-current corp_gamma corp_gamma:user_gamma
|
|
helper assert-duplicate-name-fallback corp_gamma "Beta Org"
|
|
expect_fail "corp_beta" "$BIN" auth status --profile "Beta Org" --format json
|
|
|
|
log "checking all friendly account selector combinations"
|
|
capture "$OUT_DIR/status-alpha-id-id.json" "$BIN" auth status --profile "corp_alpha:user_alpha" --format json
|
|
helper assert-status-json "$OUT_DIR/status-alpha-id-id.json" corp_alpha "Alpha Org" user_alpha
|
|
capture "$OUT_DIR/status-alpha-id-name.json" "$BIN" auth status --profile "corp_alpha:Alice Alpha" --format json
|
|
helper assert-status-json "$OUT_DIR/status-alpha-id-name.json" corp_alpha "Alpha Org" user_alpha
|
|
capture "$OUT_DIR/status-alpha-name-id.json" "$BIN" auth status --profile "Alpha Org:user_alpha" --format json
|
|
helper assert-status-json "$OUT_DIR/status-alpha-name-id.json" corp_alpha "Alpha Org" user_alpha
|
|
capture "$OUT_DIR/status-alpha-name-name.json" "$BIN" auth status --profile "Alpha Org:Alice Alpha" --format json
|
|
helper assert-status-json "$OUT_DIR/status-alpha-name-name.json" corp_alpha "Alpha Org" user_alpha
|
|
helper assert-profiles 3 _ corp_gamma:user_gamma corp_beta:user_beta
|
|
|
|
log "switching profiles and verifying legacy mirror"
|
|
capture "$OUT_DIR/switch-alpha.json" "$BIN" profile switch corp_alpha --format json
|
|
helper assert-switch-json "$OUT_DIR/switch-alpha.json" corp_alpha "Alpha Org"
|
|
helper assert-profiles 3 _ corp_alpha:user_alpha corp_gamma:user_gamma
|
|
helper assert-token default corp_alpha access-alpha-v1
|
|
capture "$OUT_DIR/switch-beta.txt" "$BIN" profile switch corp_beta --format table
|
|
expect_contains "$OUT_DIR/switch-beta.txt" "Beta Org"
|
|
expect_contains "$OUT_DIR/switch-beta.txt" "corp_beta"
|
|
helper assert-profiles 3 _ corp_beta:user_beta corp_alpha:user_alpha
|
|
helper assert-token default corp_beta access-beta-v2
|
|
capture "$OUT_DIR/switch-previous.json" "$BIN" profile switch - --format json
|
|
helper assert-switch-json "$OUT_DIR/switch-previous.json" corp_alpha "Alpha Org"
|
|
helper assert-profiles 3 _ corp_alpha:user_alpha corp_beta:user_beta
|
|
capture "$OUT_DIR/use-gamma.json" "$BIN" profile use corp_gamma --format json
|
|
helper assert-switch-json "$OUT_DIR/use-gamma.json" corp_gamma "Beta Org"
|
|
helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha
|
|
|
|
log "checking profile switch validation"
|
|
expect_fail "profile selector required" "$BIN" profile switch
|
|
expect_fail "只能指定一个组织选择器" "$BIN" profile switch corp_alpha --corpId corp_beta
|
|
expect_fail "missing_org" "$BIN" profile switch missing_org
|
|
|
|
log "checking one-shot profile override without changing current profile"
|
|
capture "$OUT_DIR/status-root-profile-alpha.json" "$BIN" --profile corp_alpha auth status --format json
|
|
helper assert-status-json "$OUT_DIR/status-root-profile-alpha.json" corp_alpha "Alpha Org" user_alpha
|
|
helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha
|
|
capture "$OUT_DIR/status-local-profile-beta.json" "$BIN" auth status --profile corp_beta --format json
|
|
helper assert-status-json "$OUT_DIR/status-local-profile-beta.json" corp_beta "Beta Org" user_beta
|
|
helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha
|
|
capture "$OUT_DIR/status-current-gamma.json" "$BIN" auth status --format json
|
|
helper assert-status-json "$OUT_DIR/status-current-gamma.json" corp_gamma "Beta Org" user_gamma
|
|
capture "$OUT_DIR/contact-multi-profile.json" "$BIN" --mock --profile corp_alpha, corp_beta contact user get-self --format json
|
|
helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile.json" 2 corp_alpha,corp_beta
|
|
helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha
|
|
capture "$OUT_DIR/contact-multi-profile-leaf-profile.json" "$BIN" --mock contact user get-self --profile corp_alpha, corp_beta --format json
|
|
helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile-leaf-profile.json" 2 corp_alpha,corp_beta
|
|
helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha
|
|
|
|
log "checking multiple accounts in the same organization"
|
|
helper seed corp_gamma "Beta Org" user_gamma_alt "Grace Gamma" access-gamma-alt-v1
|
|
capture "$OUT_DIR/list-same-corp-accounts.json" "$BIN" profile list --format json
|
|
helper assert-list-json "$OUT_DIR/list-same-corp-accounts.json" 4 _ corp_gamma:user_gamma_alt corp_gamma:user_gamma
|
|
helper assert-profiles 4 _ corp_gamma:user_gamma_alt corp_gamma:user_gamma
|
|
helper assert-org-current corp_gamma corp_gamma:user_gamma_alt
|
|
helper assert-list-order "$OUT_DIR/list-same-corp-accounts.json" "corp_alpha:user_alpha,corp_beta:user_beta,corp_gamma:user_gamma,corp_gamma:user_gamma_alt"
|
|
expect_contains "$OUT_DIR/list-same-corp-accounts.json" '"profile": "corp_gamma:user_gamma"'
|
|
expect_contains "$OUT_DIR/list-same-corp-accounts.json" '"profile": "corp_gamma:user_gamma_alt"'
|
|
helper assert-token corp_gamma:user_gamma corp_gamma access-gamma-v1
|
|
helper assert-token corp_gamma:user_gamma_alt corp_gamma access-gamma-alt-v1
|
|
helper assert-token corp_gamma corp_gamma access-gamma-alt-v1
|
|
|
|
capture "$OUT_DIR/switch-gamma-exact.json" "$BIN" profile switch corp_gamma:user_gamma --format json
|
|
helper assert-switch-json "$OUT_DIR/switch-gamma-exact.json" corp_gamma "Beta Org"
|
|
helper assert-profiles 4 _ corp_gamma:user_gamma corp_gamma:user_gamma_alt
|
|
helper assert-org-current corp_gamma corp_gamma:user_gamma
|
|
helper assert-token default corp_gamma access-gamma-v1
|
|
helper assert-token corp_gamma corp_gamma access-gamma-v1
|
|
|
|
capture "$OUT_DIR/contact-same-corp-multi.json" "$BIN" --mock --profile corp_gamma,corp_gamma:user_gamma,corp_gamma:user_gamma_alt contact user get-self --format json
|
|
helper assert-multi-profile-json "$OUT_DIR/contact-same-corp-multi.json" 2 corp_gamma,corp_gamma
|
|
expect_contains "$OUT_DIR/contact-same-corp-multi.json" '"profile": "corp_gamma:user_gamma"'
|
|
expect_contains "$OUT_DIR/contact-same-corp-multi.json" '"profile": "corp_gamma:user_gamma_alt"'
|
|
|
|
log "checking duplicate user names and missing organization default"
|
|
helper seed corp_gamma "Beta Org" user_gamma_dup "Grace Gamma" access-gamma-dup-v1
|
|
expect_fail "corp_gamma:user_gamma_alt" "$BIN" auth status --profile "corp_gamma:Grace Gamma" --format json
|
|
helper delete corp_gamma:user_gamma_dup
|
|
helper assert-profiles 4 _ corp_gamma:user_gamma _
|
|
helper assert-org-current corp_gamma _
|
|
expect_fail "corp_gamma:user_gamma" "$BIN" auth status --profile corp_gamma --format json
|
|
capture "$OUT_DIR/status-gamma-exact-after-clear.json" "$BIN" auth status --profile corp_gamma:user_gamma --format json
|
|
helper assert-status-json "$OUT_DIR/status-gamma-exact-after-clear.json" corp_gamma "Beta Org" user_gamma
|
|
|
|
log "checking profile list uses identity token truth without refresh"
|
|
helper seed-expired corp_expired "Expired Org" user_expired "Eve Expired" access-expired-v1
|
|
capture "$OUT_DIR/list-expired.json" "$BIN" profile list --format json
|
|
helper assert-list-account "$OUT_DIR/list-expired.json" corp_expired:user_expired expired true true
|
|
capture "$OUT_DIR/list-table.txt" "$BIN" profile list --format table
|
|
expect_not_contains "$OUT_DIR/list-table.txt" "PRI"
|
|
|
|
log "checking exact-account, organization, and all-account logout"
|
|
run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout --profile corp_gamma:user_gamma_alt
|
|
helper assert-token-missing corp_gamma:user_gamma_alt
|
|
helper assert-profiles 4 _ corp_expired:user_expired corp_gamma:user_gamma
|
|
helper assert-org-current corp_gamma _
|
|
|
|
run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout --profile corp_gamma
|
|
helper assert-token-missing corp_gamma:user_gamma
|
|
helper assert-token-missing corp_gamma
|
|
helper assert-profiles 3 _ corp_expired:user_expired _
|
|
|
|
run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout --profile "corp_expired:Eve Expired"
|
|
helper assert-token-missing corp_expired:user_expired
|
|
helper assert-token-missing default
|
|
helper assert-profiles 2 _ _ _
|
|
|
|
run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout --profile corp_beta
|
|
helper assert-token-missing corp_beta:user_beta
|
|
helper assert-profiles 1 _ corp_alpha:user_alpha _
|
|
|
|
run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout
|
|
helper assert-empty-auth
|
|
|
|
log "checking auth reset cleanup"
|
|
helper seed corp_reset "Reset Org" user_reset "Rita Reset" access-reset-v1
|
|
helper seed-orphan corp_orphan "Orphan Org" user_orphan "Oscar Orphan" access-orphan-v1
|
|
helper write-app-config client-reset secret-reset
|
|
helper assert-app-config exists
|
|
capture "$OUT_DIR/auth-reset.txt" "$BIN" auth reset
|
|
expect_contains "$OUT_DIR/auth-reset.txt" "[OK]"
|
|
helper assert-empty-auth
|
|
helper assert-token-missing corp_reset:user_reset
|
|
helper assert-token-missing corp_orphan:user_orphan
|
|
helper assert-app-config absent
|
|
|
|
log "checking legacy single-slot migration"
|
|
helper seed-legacy corp_legacy "Legacy Org" user_legacy "Lena Legacy" access-legacy-v1
|
|
helper assert-profiles 0 _ _ _
|
|
capture "$OUT_DIR/list-legacy-migrated.json" "$BIN" profile list --format json
|
|
helper assert-list-json "$OUT_DIR/list-legacy-migrated.json" 1 _ corp_legacy:user_legacy _
|
|
helper assert-profiles 1 _ corp_legacy:user_legacy _
|
|
helper assert-org-current corp_legacy corp_legacy:user_legacy
|
|
helper assert-token default corp_legacy access-legacy-v1
|
|
helper assert-token corp_legacy corp_legacy access-legacy-v1
|
|
|
|
log "multi-profile e2e passed"
|
|
echo "[PASS] isolated multi-profile chain completed"
|