Files
dingtalk-workspace-cli/internal/auth/portable_store_test.go
audanye-sudo 93854178e3 feat(auth): support exact multi-account profile identities
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.
2026-07-17 00:56:43 +08:00

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()
}