Merge branch 'main' into codex/sync-wukong-capabilities-20260723

This commit is contained in:
修雨
2026-07-23 18:17:36 +08:00
committed by GitHub
40 changed files with 9661 additions and 331 deletions
+574
View File
@@ -0,0 +1,574 @@
package smoke_test
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
// TestCLISmoke_LegacyAuthSlotsMigrateThroughStatus exercises the upgrade
// boundary through a real CLI subprocess. Fixtures are deliberately written
// as version-1 JSON and raw historical keychain accounts; using the current
// auth SaveTokenData API here would only prove that the new format can read
// itself.
func TestCLISmoke_LegacyAuthSlotsMigrateThroughStatus(t *testing.T) {
t.Run("v1.0.51 global-only slot", func(t *testing.T) {
env, configDir := isolatedLegacyAuthCLIEnv(t)
corpID, userID := legacyFixtureIdentity(t, "global")
accessToken := "fixture-access-global"
writeLegacyProfilesV1(t, configDir, []legacySmokeProfile{{
Name: "Legacy Global Org", CorpID: corpID, CorpName: "Legacy Global Org", UserID: userID,
}}, corpID)
seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken(
t, accessToken, corpID, "Legacy Global Org", "", "",
))
status := runLegacyAuthStatus(t, env)
assertAuthenticatedStatus(t, status, corpID, userID)
assertRawIdentitySlot(t, corpID, userID, accessToken)
assertMigratedProfiles(t, configDir, map[string]string{corpID: userID})
})
t.Run("v1.0.52 migrates every organization", func(t *testing.T) {
env, configDir := isolatedLegacyAuthCLIEnv(t)
profiles := make([]legacySmokeProfile, 0, 3)
wantIdentities := make(map[string]string, 3)
wantAccess := make(map[string]string, 3)
for i := 0; i < 3; i++ {
corpID, userID := legacyFixtureIdentity(t, fmt.Sprintf("org-%d", i))
corpName := fmt.Sprintf("Legacy Multi Org %d", i+1)
accessToken := fmt.Sprintf("fixture-access-multi-%d", i+1)
profiles = append(profiles, legacySmokeProfile{
Name: corpName, CorpID: corpID, CorpName: corpName, UserID: userID,
})
wantIdentities[corpID] = userID
wantAccess[corpID] = accessToken
// v1.0.52 kept one token per organization. The token response could
// omit userId even when the v1 profile registry still knew it.
seedRawLegacyKeychainSlot(t, legacyOrganizationAccount(corpID), legacyRawToken(
t, accessToken, corpID, corpName, "", "",
))
}
current := profiles[1]
writeLegacyProfilesV1(t, configDir, profiles, current.CorpID)
// v1.0.52 also mirrored the selected organization in the global slot.
seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken(
t, wantAccess[current.CorpID], current.CorpID, current.CorpName, "", "",
))
status := runLegacyAuthStatus(t, env)
assertAuthenticatedStatus(t, status, current.CorpID, current.UserID)
for corpID, userID := range wantIdentities {
assertRawIdentitySlot(t, corpID, userID, wantAccess[corpID])
}
assertMigratedProfiles(t, configDir, wantIdentities)
})
t.Run("v1.0.52 unresolved external account remains usable", func(t *testing.T) {
env, configDir := isolatedLegacyAuthCLIEnv(t)
corpID, _ := legacyFixtureIdentity(t, "external")
accessToken := "fixture-access-external"
writeLegacyProfilesV1(t, configDir, []legacySmokeProfile{{
Name: "Legacy External Org", CorpID: corpID, CorpName: "Legacy External Org",
}}, corpID)
seedRawLegacyKeychainSlot(t, legacyOrganizationAccount(corpID), legacyRawToken(
t, accessToken, corpID, "Legacy External Org", "", "",
))
seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken(
t, accessToken, corpID, "Legacy External Org", "", "",
))
status := runLegacyAuthStatus(t, env)
assertAuthenticatedStatus(t, status, corpID, "")
assertRawOrganizationSlot(t, corpID, accessToken, "")
var migrated legacyProfilesDocument
readJSONFile(t, filepath.Join(configDir, "profiles.json"), &migrated)
if migrated.Version != 2 || len(migrated.Profiles) != 1 {
t.Fatalf("migrated unresolved profiles = %#v", migrated)
}
if migrated.Profiles[0].CorpID != corpID || migrated.Profiles[0].UserID != "" {
t.Fatalf("unresolved external identity was changed or discarded: %#v", migrated.Profiles[0])
}
})
t.Run("v1.0.53 v2 exact profile repairs global-only slot", func(t *testing.T) {
for _, tc := range []struct {
name string
globalTokenUserID bool
}{
{name: "global token omits user id"},
{name: "global token user id matches", globalTokenUserID: true},
} {
t.Run(tc.name, func(t *testing.T) {
env, configDir := isolatedLegacyAuthCLIEnv(t)
corpID, userID := legacyFixtureIdentity(t, "v2-exact-"+tc.name)
profile := legacySmokeProfile{
Name: "Half Migrated Exact Account", CorpID: corpID,
CorpName: "Half Migrated Exact Org", UserID: userID,
}
accessToken := "fixture-access-v2-exact-" + strings.ReplaceAll(tc.name, " ", "-")
writeHalfMigratedProfilesV2(t, configDir, profile)
tokenUserID := ""
if tc.globalTokenUserID {
tokenUserID = userID
}
// Reproduce the interrupted 1.0.53 migration exactly: the registry is
// already v2, while token material still exists only in the global
// compatibility account. Do not seed either the org or identity slot.
seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken(
t, accessToken, corpID, profile.CorpName, tokenUserID, "",
))
exactSelector := corpID + ":" + userID
status := runLegacyAuthStatus(t, env, exactSelector)
assertAuthenticatedStatus(t, status, corpID, userID)
assertRawIdentitySlot(t, corpID, userID, accessToken)
assertRawOrganizationSlot(t, corpID, accessToken, tokenUserID)
assertMigratedProfiles(t, configDir, map[string]string{corpID: userID})
})
}
})
t.Run("v1.0.53 v2 unresolved profile repairs global-only organization slot", func(t *testing.T) {
env, configDir := isolatedLegacyAuthCLIEnv(t)
corpID, _ := legacyFixtureIdentity(t, "v2-unresolved")
profile := legacySmokeProfile{
Name: "Half Migrated External Account", CorpID: corpID,
CorpName: "Half Migrated External Org",
}
accessToken := "fixture-access-v2-unresolved"
writeHalfMigratedProfilesV2(t, configDir, profile)
seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken(
t, accessToken, corpID, profile.CorpName, "", "",
))
status := runLegacyAuthStatus(t, env, profile.Name)
assertAuthenticatedStatus(t, status, corpID, "")
assertRawOrganizationSlot(t, corpID, accessToken, "")
var migrated legacyProfilesDocument
readJSONFile(t, filepath.Join(configDir, "profiles.json"), &migrated)
if migrated.Version != 2 || len(migrated.Profiles) != 1 {
t.Fatalf("repaired unresolved v2 profiles = %#v", migrated)
}
if migrated.Profiles[0].CorpID != corpID || migrated.Profiles[0].UserID != "" {
t.Fatalf("unresolved v2 identity was changed or discarded: %#v", migrated.Profiles[0])
}
})
}
// TestCLISmoke_ReservedUnresolvedAndExactProfilesStayIsolated protects the
// schema-v3 selector boundary through real CLI subprocesses. A blank external
// identity and a resolved account may legitimately coexist in one
// organization; selecting either must never read or rewrite the other's slot.
func TestCLISmoke_ReservedUnresolvedAndExactProfilesStayIsolated(t *testing.T) {
env, configDir := isolatedLegacyAuthCLIEnv(t)
corpID, exactUserID := legacyFixtureIdentity(t, "schema-v3-blank-exact")
reservedSelector := "@legacy/" + base64.RawURLEncoding.EncodeToString([]byte(corpID))
exactSelector := corpID + ":" + exactUserID
blankAccessToken := "fixture-access-schema-v3-blank"
exactAccessToken := "fixture-access-schema-v3-exact"
blankRawToken := legacyRawToken(
t, blankAccessToken, corpID, "Schema V3 Fixture Org", "", "",
)
exactRawToken := legacyRawToken(
t, exactAccessToken, corpID, "Schema V3 Fixture Org", exactUserID, "Exact Fixture Account",
)
writeCoexistingProfilesV3(t, configDir, corpID, exactUserID, reservedSelector, exactSelector)
organizationAccount := legacyOrganizationAccount(corpID)
identityAccount := legacyIdentityAccount(corpID, exactUserID)
seedRawLegacyKeychainSlot(t, organizationAccount, blankRawToken)
seedRawLegacyKeychainSlot(t, identityAccount, exactRawToken)
defaultStatus := runLegacyAuthStatus(t, env)
assertAuthenticatedStatus(t, defaultStatus, corpID, "")
exactStatus := runLegacyAuthStatus(t, env, exactSelector)
assertAuthenticatedStatus(t, exactStatus, corpID, exactUserID)
assertRawKeychainSlotUnchanged(t, organizationAccount, blankRawToken)
assertRawKeychainSlotUnchanged(t, identityAccount, exactRawToken)
var profiles legacyProfilesDocument
readJSONFile(t, filepath.Join(configDir, "profiles.json"), &profiles)
if profiles.Version != 3 {
t.Fatalf("profiles version = %d, want 3", profiles.Version)
}
if profiles.CurrentProfile != reservedSelector {
t.Fatalf("current profile = %q, want reserved blank selector %q", profiles.CurrentProfile, reservedSelector)
}
if profiles.OrgCurrentProfiles[corpID] != exactSelector {
t.Fatalf(
"orgCurrentProfiles[%q] = %q, want exact selector %q",
corpID,
profiles.OrgCurrentProfiles[corpID],
exactSelector,
)
}
if len(profiles.Profiles) != 2 {
t.Fatalf("profiles = %#v, want one blank and one exact identity", profiles.Profiles)
}
identities := map[string]int{}
for _, profile := range profiles.Profiles {
if profile.CorpID != corpID {
t.Fatalf("profile corpId = %q, want %q", profile.CorpID, corpID)
}
identities[profile.UserID]++
}
if identities[""] != 1 || identities[exactUserID] != 1 {
t.Fatalf("profile identities = %#v, want one blank and one exact %q", identities, exactUserID)
}
}
type legacySmokeProfile struct {
Name string
CorpID string
CorpName string
UserID string
UserName string
}
type legacyProfilesDocument struct {
Version int `json:"version"`
CurrentProfile string `json:"currentProfile"`
OrgCurrentProfiles map[string]string `json:"orgCurrentProfiles"`
Profiles []struct {
CorpID string `json:"corpId"`
UserID string `json:"userId"`
} `json:"profiles"`
}
type legacyAuthStatus struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
Refreshed bool `json:"refreshed"`
TokenValid bool `json:"token_valid"`
CorpID string `json:"corp_id"`
UserID string `json:"user_id"`
}
func isolatedLegacyAuthCLIEnv(t *testing.T) ([]string, string) {
t.Helper()
env := isolatedCLIEnv(t)
configDir := smokeEnvValue(t, env, "DWS_CONFIG_DIR")
// File-backed platforms are already isolated by DWS_KEYCHAIN_DIR. Windows
// uses HKCU, so give the parent seeder and CLI child the same throwaway
// registry namespace as well.
namespace := filepath.Join(configDir, "legacy-auth-smoke")
t.Setenv(keychain.TestNamespaceEnv, namespace)
env = append(env, keychain.TestNamespaceEnv+"="+namespace)
sort.Strings(env)
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
t.Fatalf("clear isolated historical keychain: %v", err)
}
t.Cleanup(func() {
_ = keychain.RemoveAuthTokenEntries(keychain.Service)
})
return env, configDir
}
func smokeEnvValue(t *testing.T, env []string, key string) string {
t.Helper()
prefix := key + "="
for _, entry := range env {
if strings.HasPrefix(entry, prefix) {
return strings.TrimPrefix(entry, prefix)
}
}
t.Fatalf("CLI environment does not contain %s", key)
return ""
}
func legacyFixtureIdentity(t *testing.T, suffix string) (string, string) {
t.Helper()
sum := sha256.Sum256([]byte(t.Name() + "\x00" + suffix))
return fmt.Sprintf("ding_fixture_%x", sum[:6]), fmt.Sprintf("fixture-user-%x", sum[6:12])
}
func writeLegacyProfilesV1(t *testing.T, configDir string, profiles []legacySmokeProfile, currentCorpID string) {
t.Helper()
rawProfiles := make([]map[string]any, 0, len(profiles))
for _, profile := range profiles {
raw := map[string]any{
"name": profile.Name,
"corpId": profile.CorpID,
"corpName": profile.CorpName,
"clientId": "fixture-legacy-client",
"status": "active",
"expiresAt": "2099-01-02T03:04:05Z",
}
if profile.UserID != "" {
raw["userId"] = profile.UserID
}
if profile.UserName != "" {
raw["userName"] = profile.UserName
}
rawProfiles = append(rawProfiles, raw)
}
document := map[string]any{
"version": 1,
"primaryProfile": profiles[0].CorpID,
"currentProfile": currentCorpID,
"profiles": rawProfiles,
}
data, err := json.MarshalIndent(document, "", " ")
if err != nil {
t.Fatalf("marshal raw v1 profiles fixture: %v", err)
}
if err := os.MkdirAll(configDir, 0o700); err != nil {
t.Fatalf("create raw v1 config directory: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, "profiles.json"), append(data, '\n'), 0o600); err != nil {
t.Fatalf("write raw v1 profiles fixture: %v", err)
}
}
func writeHalfMigratedProfilesV2(t *testing.T, configDir string, profile legacySmokeProfile) {
t.Helper()
selector := profile.CorpID
orgCurrentProfiles := map[string]string{}
if profile.UserID != "" {
selector = profile.CorpID + ":" + profile.UserID
orgCurrentProfiles[profile.CorpID] = selector
}
rawProfile := map[string]any{
"name": profile.Name,
"corpId": profile.CorpID,
"corpName": profile.CorpName,
"clientId": "fixture-legacy-client",
"status": "active",
"expiresAt": "2099-01-02T03:04:05Z",
}
if profile.UserID != "" {
rawProfile["userId"] = profile.UserID
}
document := map[string]any{
"version": 2,
"primaryProfile": selector,
"currentProfile": selector,
"orgCurrentProfiles": orgCurrentProfiles,
"profiles": []map[string]any{rawProfile},
}
data, err := json.MarshalIndent(document, "", " ")
if err != nil {
t.Fatalf("marshal raw half-migrated v2 profiles fixture: %v", err)
}
if err := os.MkdirAll(configDir, 0o700); err != nil {
t.Fatalf("create raw v2 config directory: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, "profiles.json"), append(data, '\n'), 0o600); err != nil {
t.Fatalf("write raw half-migrated v2 profiles fixture: %v", err)
}
}
func writeCoexistingProfilesV3(
t *testing.T,
configDir string,
corpID string,
exactUserID string,
reservedSelector string,
exactSelector string,
) {
t.Helper()
document := map[string]any{
"version": 3,
"primaryProfile": exactSelector,
"currentProfile": reservedSelector,
"previousProfile": exactSelector,
"orgCurrentProfiles": map[string]string{corpID: exactSelector},
"profiles": []map[string]any{
{
"name": "Schema V3 Fixture Org",
"corpId": corpID,
"corpName": "Schema V3 Fixture Org",
"clientId": "fixture-legacy-client",
"status": "active",
"expiresAt": "2099-01-02T03:04:05Z",
},
{
"name": "Schema V3 Exact Fixture",
"corpId": corpID,
"corpName": "Schema V3 Fixture Org",
"userId": exactUserID,
"userName": "Exact Fixture Account",
"clientId": "fixture-legacy-client",
"status": "active",
"expiresAt": "2099-01-02T03:04:05Z",
},
},
}
data, err := json.MarshalIndent(document, "", " ")
if err != nil {
t.Fatalf("marshal raw coexisting v3 profiles fixture: %v", err)
}
if err := os.MkdirAll(configDir, 0o700); err != nil {
t.Fatalf("create raw v3 config directory: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, "profiles.json"), append(data, '\n'), 0o600); err != nil {
t.Fatalf("write raw coexisting v3 profiles fixture: %v", err)
}
}
func legacyRawToken(t *testing.T, accessToken, corpID, corpName, userID, userName string) string {
t.Helper()
document := map[string]any{
"access_token": accessToken,
"refresh_token": "fixture-refresh-" + accessToken,
"persistent_code": "fixture-persistent-" + accessToken,
"expires_at": "2099-01-02T03:04:05Z",
"refresh_expires_at": "2099-02-02T03:04:05Z",
"corp_id": corpID,
"corp_name": corpName,
"client_id": "fixture-legacy-client",
"source": "mcp",
}
if userID != "" {
document["user_id"] = userID
}
if userName != "" {
document["user_name"] = userName
}
data, err := json.MarshalIndent(document, "", " ")
if err != nil {
t.Fatalf("marshal raw historical token: %v", err)
}
return string(data)
}
func seedRawLegacyKeychainSlot(t *testing.T, account, rawToken string) {
t.Helper()
// Write the old account directly. Do not route setup through auth.SaveTokenData,
// which would eagerly create the very identity slot this E2E must observe.
if err := keychain.Set(keychain.Service, account, rawToken); err != nil {
t.Fatalf("write raw historical keychain account %q: %v", account, err)
}
}
func assertRawKeychainSlotUnchanged(t *testing.T, account, want string) {
t.Helper()
got, err := keychain.Get(keychain.Service, account)
if err != nil {
t.Fatalf("read raw keychain account %q: %v", account, err)
}
if got != want {
t.Fatalf("raw keychain account %q changed\nwant:\n%s\ngot:\n%s", account, want, got)
}
}
func runLegacyAuthStatus(t *testing.T, env []string, profile ...string) legacyAuthStatus {
t.Helper()
if len(profile) > 1 {
t.Fatalf("auth status accepts at most one profile selector, got %d", len(profile))
}
args := []string{"--format", "json", "auth", "status"}
if len(profile) == 1 && strings.TrimSpace(profile[0]) != "" {
args = append(args, "--profile", profile[0])
}
stdout, stderr, err := runCLI(t, env, args...)
if err != nil {
t.Fatalf("dws auth status failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
}
var status legacyAuthStatus
if err := json.Unmarshal([]byte(stdout), &status); err != nil {
t.Fatalf("dws auth status returned invalid JSON: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
}
return status
}
func assertAuthenticatedStatus(t *testing.T, status legacyAuthStatus, corpID, userID string) {
t.Helper()
if !status.Success || !status.Authenticated || !status.TokenValid || status.Refreshed {
t.Fatalf("auth status did not reuse valid historical login: %#v", status)
}
if status.CorpID != corpID || status.UserID != userID {
t.Fatalf("auth status identity = %q:%q, want %q:%q", status.CorpID, status.UserID, corpID, userID)
}
}
func assertRawIdentitySlot(t *testing.T, corpID, userID, accessToken string) {
t.Helper()
account := legacyIdentityAccount(corpID, userID)
raw, err := keychain.Get(keychain.Service, account)
if err != nil {
t.Fatalf("read migrated identity account %q: %v", account, err)
}
if raw == "" {
t.Fatalf("migrated identity account %q is missing", account)
}
var token map[string]any
if err := json.Unmarshal([]byte(raw), &token); err != nil {
t.Fatalf("parse migrated identity account %q: %v", account, err)
}
if token["access_token"] != accessToken || token["corp_id"] != corpID || token["user_id"] != userID {
t.Fatalf("migrated identity account %q = %#v", account, token)
}
}
func assertRawOrganizationSlot(t *testing.T, corpID, accessToken, userID string) {
t.Helper()
account := legacyOrganizationAccount(corpID)
raw, err := keychain.Get(keychain.Service, account)
if err != nil {
t.Fatalf("read organization account %q: %v", account, err)
}
var token map[string]any
if err := json.Unmarshal([]byte(raw), &token); err != nil {
t.Fatalf("parse organization account %q: %v", account, err)
}
if token["access_token"] != accessToken || token["corp_id"] != corpID {
t.Fatalf("organization account %q = %#v", account, token)
}
if got, _ := token["user_id"].(string); got != userID {
t.Fatalf("organization account %q user_id = %q, want %q", account, got, userID)
}
}
func assertMigratedProfiles(t *testing.T, configDir string, identities map[string]string) {
t.Helper()
var migrated legacyProfilesDocument
readJSONFile(t, filepath.Join(configDir, "profiles.json"), &migrated)
if migrated.Version != 2 {
t.Fatalf("profiles version = %d, want 2", migrated.Version)
}
if len(migrated.Profiles) != len(identities) {
t.Fatalf("migrated profile count = %d, want %d", len(migrated.Profiles), len(identities))
}
for corpID, userID := range identities {
selector := corpID + ":" + userID
if migrated.OrgCurrentProfiles[corpID] != selector {
t.Errorf("orgCurrentProfiles[%q] = %q, want %q", corpID, migrated.OrgCurrentProfiles[corpID], selector)
}
}
}
func readJSONFile(t *testing.T, path string, target any) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if err := json.Unmarshal(data, target); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
}
func legacyOrganizationAccount(corpID string) string {
return keychain.AccountToken + ":" + strings.TrimSpace(corpID)
}
func legacyIdentityAccount(corpID, userID string) string {
identity := strings.TrimSpace(corpID) + "\x00" + strings.TrimSpace(userID)
return fmt.Sprintf("%s:id:%x", keychain.AccountToken, sha256.Sum256([]byte(identity)))
}