Store DingTalk credentials in corpId:userId identity slots while retaining organization and legacy mirrors for forward compatibility. Resolve organization, account, friendly-name, current, previous, and deletion selectors without silently choosing among ambiguous accounts. Make identity tokens the source of truth, serialize migration and refresh reads, reject unsafe mirror recovery, and sweep orphan token entries during reset. Persist token source and client ID for exact remote revocation, require user identity before first-login persistence, and add cross-platform regression coverage for migration, deletion, refresh, and keychain failures.
417 lines
14 KiB
Go
417 lines
14 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 auth
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
|
)
|
|
|
|
func TestCrossPlatformCoveragePortableExportSupported(t *testing.T) {
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
if PortableExportSupported() {
|
|
t.Fatal("PortableExportSupported() should be false for the Windows DPAPI backend")
|
|
}
|
|
var bundle bytes.Buffer
|
|
err := ExportPortableAuthBundle(t.TempDir(), &bundle)
|
|
if err == nil || !strings.Contains(err.Error(), "DPAPI") {
|
|
t.Fatalf("ExportPortableAuthBundle() error = %v, want Windows DPAPI unsupported error", err)
|
|
}
|
|
if bundle.Len() != 0 {
|
|
t.Fatalf("ExportPortableAuthBundle() wrote %d bytes on unsupported Windows backend", bundle.Len())
|
|
}
|
|
return
|
|
case "darwin":
|
|
t.Setenv(keychain.DisableKeychainEnv, "")
|
|
if PortableExportSupported() {
|
|
t.Fatal("PortableExportSupported() should be false on darwin without file DEK")
|
|
}
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
if !PortableExportSupported() {
|
|
t.Fatal("PortableExportSupported() should be true when file DEK is enabled")
|
|
}
|
|
return
|
|
default:
|
|
if !PortableExportSupported() {
|
|
t.Fatal("PortableExportSupported() should be true for file-DEK platforms")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePortableExportSupportError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
goos string
|
|
disableKeychain string
|
|
wantError string
|
|
}{
|
|
{name: "windows dpapi", goos: "windows", disableKeychain: "1", wantError: "DPAPI"},
|
|
{name: "darwin keychain", goos: "darwin", wantError: keychain.DisableKeychainEnv},
|
|
{name: "darwin file dek", goos: "darwin", disableKeychain: "1"},
|
|
{name: "linux file dek", goos: "linux"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
err := portableExportSupportError(test.goos, test.disableKeychain)
|
|
if test.wantError == "" {
|
|
if err != nil {
|
|
t.Fatalf("portableExportSupportError() error = %v, want nil", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
|
t.Fatalf("portableExportSupportError() error = %v, want substring %q", err, test.wantError)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePortableImportSupportError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
goos string
|
|
wantError string
|
|
}{
|
|
{name: "windows dpapi", goos: "windows", wantError: "DPAPI"},
|
|
{name: "darwin", goos: "darwin"},
|
|
{name: "linux", goos: "linux"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
err := portableImportSupportError(test.goos)
|
|
if test.wantError == "" {
|
|
if err != nil {
|
|
t.Fatalf("portableImportSupportError() error = %v, want nil", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
|
t.Fatalf("portableImportSupportError() error = %v, want substring %q", err, test.wantError)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageImportPortableAuthBundleRejectsWindowsDPAPIWithoutMutation(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
t.Skip("Windows DPAPI contract requires a native Windows runner")
|
|
}
|
|
|
|
root := t.TempDir()
|
|
configDir := filepath.Join(root, ".dws")
|
|
keychainDir := filepath.Join(root, "keychain")
|
|
t.Setenv(keychain.StorageDirEnv, keychainDir)
|
|
bundle := validPortableAuthBundleForTest(t)
|
|
|
|
if _, err := ImportPortableAuthBundle(configDir, bytes.NewReader(bundle)); err == nil || !strings.Contains(err.Error(), "DPAPI") {
|
|
t.Fatalf("ImportPortableAuthBundle() error = %v, want Windows DPAPI unsupported error", err)
|
|
}
|
|
for _, path := range []string{configDir, keychainDir} {
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("unsupported Windows import touched %s: stat error = %v", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExportPortableAuthBundleRequiresAuthToken(t *testing.T) {
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
keychainRoot := filepath.Join(t.TempDir(), "empty-keychain")
|
|
if err := os.MkdirAll(filepath.Join(keychainRoot, keychain.Service), 0o700); err != nil {
|
|
t.Fatalf("MkdirAll() error = %v", err)
|
|
}
|
|
t.Setenv(keychain.StorageDirEnv, keychainRoot)
|
|
configDir := filepath.Join(t.TempDir(), ".dws")
|
|
|
|
var bundle bytes.Buffer
|
|
err := ExportPortableAuthBundle(configDir, &bundle)
|
|
if err == nil {
|
|
t.Fatal("ExportPortableAuthBundle() should fail without auth-token.enc")
|
|
}
|
|
if bundle.Len() != 0 {
|
|
t.Fatalf("ExportPortableAuthBundle() wrote %d bytes, want 0", bundle.Len())
|
|
}
|
|
}
|
|
|
|
func TestPortableAuthTargetPopulated(t *testing.T) {
|
|
requirePortableFileBackend(t)
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
root := t.TempDir()
|
|
configDir := filepath.Join(root, ".dws")
|
|
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
|
|
|
|
if PortableAuthTargetPopulated(configDir) {
|
|
t.Fatal("PortableAuthTargetPopulated() should be false before save")
|
|
}
|
|
if err := SaveTokenData(configDir, &TokenData{
|
|
AccessToken: "token",
|
|
RefreshToken: "refresh",
|
|
RefreshExpAt: time.Now().Add(time.Hour),
|
|
}); err != nil {
|
|
t.Fatalf("SaveTokenData() error = %v", err)
|
|
}
|
|
if !PortableAuthTargetPopulated(configDir) {
|
|
t.Fatal("PortableAuthTargetPopulated() should be true after save")
|
|
}
|
|
}
|
|
|
|
func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) {
|
|
requirePortableFileBackend(t)
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
|
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
|
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
|
|
|
original := &TokenData{
|
|
AccessToken: "access-source",
|
|
RefreshToken: "refresh-source",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
|
CorpID: "dingcorp",
|
|
ClientID: "client-from-mcp",
|
|
Source: "mcp",
|
|
}
|
|
if err := SaveTokenData(sourceConfig, original); err != nil {
|
|
t.Fatalf("SaveTokenData() error = %v", err)
|
|
}
|
|
if err := SaveAppConfig(sourceConfig, &AppConfig{ClientID: "client-from-mcp"}); err != nil {
|
|
t.Fatalf("SaveAppConfig() error = %v", err)
|
|
}
|
|
|
|
var bundle bytes.Buffer
|
|
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
|
|
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
|
|
}
|
|
if bundle.Len() == 0 {
|
|
t.Fatal("ExportPortableAuthBundle() wrote an empty bundle")
|
|
}
|
|
|
|
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
|
|
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
|
targetConfig := filepath.Join(t.TempDir(), ".dws")
|
|
|
|
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
|
|
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
|
|
}
|
|
|
|
loaded, err := LoadTokenData(targetConfig)
|
|
if err != nil {
|
|
t.Fatalf("LoadTokenData() after import error = %v", err)
|
|
}
|
|
if loaded.AccessToken != original.AccessToken {
|
|
t.Fatalf("access token = %q, want %q", loaded.AccessToken, original.AccessToken)
|
|
}
|
|
if loaded.RefreshToken != original.RefreshToken {
|
|
t.Fatalf("refresh token = %q, want %q", loaded.RefreshToken, original.RefreshToken)
|
|
}
|
|
if !loaded.IsRefreshTokenValid() {
|
|
t.Fatal("refresh token should remain valid after import")
|
|
}
|
|
if cfg, err := LoadAppConfig(targetConfig); err != nil {
|
|
t.Fatalf("LoadAppConfig() after import error = %v", err)
|
|
} else if cfg == nil || cfg.ClientID != "client-from-mcp" {
|
|
t.Fatalf("imported app config = %#v, want client ID preserved", cfg)
|
|
}
|
|
}
|
|
|
|
func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
|
|
requirePortableFileBackend(t)
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
SetRuntimeProfile("")
|
|
t.Cleanup(func() { SetRuntimeProfile("") })
|
|
|
|
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
|
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
|
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
|
|
|
tokenA := &TokenData{
|
|
AccessToken: "access-a",
|
|
RefreshToken: "refresh-a",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
|
CorpID: "corp_a",
|
|
CorpName: "A Org",
|
|
ClientID: "client-a",
|
|
}
|
|
tokenB := &TokenData{
|
|
AccessToken: "access-b",
|
|
RefreshToken: "refresh-b",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
|
CorpID: "corp_b",
|
|
CorpName: "B Org",
|
|
ClientID: "client-b",
|
|
}
|
|
if err := SaveTokenData(sourceConfig, tokenA); err != nil {
|
|
t.Fatalf("SaveTokenData(A) error = %v", err)
|
|
}
|
|
if err := SaveTokenData(sourceConfig, tokenB); err != nil {
|
|
t.Fatalf("SaveTokenData(B) error = %v", err)
|
|
}
|
|
|
|
var bundle bytes.Buffer
|
|
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
|
|
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
|
|
}
|
|
|
|
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
|
|
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
|
targetConfig := filepath.Join(t.TempDir(), ".dws")
|
|
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
|
|
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
|
|
}
|
|
|
|
cfg, err := LoadProfiles(targetConfig)
|
|
if err != nil {
|
|
t.Fatalf("LoadProfiles() after import error = %v", err)
|
|
}
|
|
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
|
|
t.Fatalf("profiles after import = %#v", cfg)
|
|
}
|
|
if len(cfg.Profiles) != 2 {
|
|
t.Fatalf("profiles len = %d, want 2: %#v", len(cfg.Profiles), cfg.Profiles)
|
|
}
|
|
|
|
loadedA, err := LoadTokenDataForProfile(targetConfig, "corp_a")
|
|
if err != nil {
|
|
t.Fatalf("LoadTokenDataForProfile(A) after import error = %v", err)
|
|
}
|
|
if loadedA.AccessToken != "access-a" {
|
|
t.Fatalf("profile A token = %q, want access-a", loadedA.AccessToken)
|
|
}
|
|
loadedB, err := LoadTokenDataForProfile(targetConfig, "corp_b")
|
|
if err != nil {
|
|
t.Fatalf("LoadTokenDataForProfile(B) after import error = %v", err)
|
|
}
|
|
if loadedB.AccessToken != "access-b" {
|
|
t.Fatalf("profile B token = %q, want access-b", loadedB.AccessToken)
|
|
}
|
|
}
|
|
|
|
func TestPortableAuthBundleRoundTripPreservesSameCorpAccounts(t *testing.T) {
|
|
requirePortableFileBackend(t)
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
SetRuntimeProfile("")
|
|
t.Cleanup(func() { SetRuntimeProfile("") })
|
|
|
|
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
|
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
|
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
|
|
|
first := &TokenData{
|
|
AccessToken: "access-first",
|
|
RefreshToken: "refresh-first",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
|
CorpID: "corp_same",
|
|
CorpName: "Same Org",
|
|
UserID: "user_1",
|
|
UserName: "账号一",
|
|
}
|
|
second := *first
|
|
second.AccessToken = "access-second"
|
|
second.RefreshToken = "refresh-second"
|
|
second.UserID = "user_2"
|
|
second.UserName = "账号二"
|
|
if err := SaveTokenData(sourceConfig, first); err != nil {
|
|
t.Fatalf("SaveTokenData(first) error = %v", err)
|
|
}
|
|
if err := SaveTokenData(sourceConfig, &second); err != nil {
|
|
t.Fatalf("SaveTokenData(second) error = %v", err)
|
|
}
|
|
|
|
var bundle bytes.Buffer
|
|
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
|
|
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
|
|
}
|
|
|
|
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
|
|
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
|
targetConfig := filepath.Join(t.TempDir(), ".dws")
|
|
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
|
|
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
|
|
}
|
|
|
|
for selector, wantToken := range map[string]string{
|
|
"corp_same:user_1": "access-first",
|
|
"corp_same:user_2": "access-second",
|
|
"corp_same": "access-second",
|
|
} {
|
|
loaded, err := LoadTokenDataForProfile(targetConfig, selector)
|
|
if err != nil {
|
|
t.Fatalf("LoadTokenDataForProfile(%s) error = %v", selector, err)
|
|
}
|
|
if loaded.AccessToken != wantToken {
|
|
t.Fatalf("profile %s token = %q, want %q", selector, loaded.AccessToken, wantToken)
|
|
}
|
|
}
|
|
}
|
|
|
|
func requirePortableFileBackend(t *testing.T) {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("portable bundle round trips require a file-DEK backend; Windows uses DPAPI registry storage")
|
|
}
|
|
}
|
|
|
|
func validPortableAuthBundleForTest(t *testing.T) []byte {
|
|
t.Helper()
|
|
|
|
var bundle bytes.Buffer
|
|
gz := gzip.NewWriter(&bundle)
|
|
tw := tar.NewWriter(gz)
|
|
manifest := portableAuthBundleManifest{
|
|
Version: 1,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
OS: "linux",
|
|
KeychainService: keychain.Service,
|
|
ConfigFiles: []string{"app.json"},
|
|
}
|
|
if err := writePortableManifest(tw, manifest); err != nil {
|
|
t.Fatalf("writePortableManifest() error = %v", err)
|
|
}
|
|
entries := map[string]string{
|
|
path.Join("keychain", keychain.Service, keychain.AccountToken+".enc"): "encrypted-token",
|
|
"config/app.json": "{}",
|
|
}
|
|
for name, content := range entries {
|
|
hdr := &tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
t.Fatalf("WriteHeader(%s) error = %v", name, err)
|
|
}
|
|
if _, err := tw.Write([]byte(content)); err != nil {
|
|
t.Fatalf("Write(%s) error = %v", name, err)
|
|
}
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatalf("tar.Close() error = %v", err)
|
|
}
|
|
if err := gz.Close(); err != nil {
|
|
t.Fatalf("gzip.Close() error = %v", err)
|
|
}
|
|
return bundle.Bytes()
|
|
}
|