830 lines
31 KiB
Bash
Executable File
830 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}"
|
|
GO_TEST_TIMEOUT="${MULTI_PROFILE_GO_TEST_TIMEOUT:-10m}"
|
|
|
|
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 != "" {
|
|
die("profile must not persist derived token expiry: %#v", p)
|
|
}
|
|
for field, value := range map[string]string{
|
|
"lastLoginAt": p.LastLoginAt,
|
|
"lastUsedAt": p.LastUsedAt,
|
|
"updatedAt": p.UpdatedAt,
|
|
} {
|
|
if _, err := time.Parse(time.RFC3339, value); err != nil {
|
|
die("profile %s %s=%q, want RFC3339 timestamp: %v", p.CorpID, field, value, err)
|
|
}
|
|
}
|
|
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 "$GO_TEST_TIMEOUT" -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/.agents/skills/dws/SKILL.md" "corpId:userId"
|
|
expect_contains "$MONO_HOME/.agents/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
|
|
expect_contains "$MONO_HOME/.agents/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/.agents/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
|
|
expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.md" "corpId:userId"
|
|
expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.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"
|