Files
dingtalk-workspace-cli/internal/auth/token_preflight_test.go
T
修雨 bfd48b6a71 fix: preserve macOS auth across keychain mode changes (#597)
* fix: preserve auth across macOS keychain modes

* docs(auth): clarify per-profile recovery

* fix(auth): add safe macOS keychain migration

* ci: add native Windows auth coverage

* ci: scope Windows checks to auth paths

* fix(auth): address keychain review boundaries
2026-07-13 15:27:39 +08:00

377 lines
13 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.
//go:build darwin
package auth
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
type preflightRoundTripFunc func(*http.Request) (*http.Response, error)
func (f preflightRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func seedUnreadableTokenStorage(t *testing.T, configDir string, data *TokenData) {
t.Helper()
t.Setenv(keychain.DisableKeychainEnv, "1")
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil {
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
}
}
func setPreflightTestCredentials(t *testing.T) {
t.Helper()
SetClientID("preflight-client-id")
SetClientSecret("preflight-client-secret")
resetClientIDFromMCP()
t.Cleanup(func() {
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
})
}
func profileCiphertextPathForTest(corpID string) string {
account := strings.ReplaceAll(TokenAccountForCorpID(corpID), ":", "_")
return filepath.Join(keychain.StorageDir(keychain.Service), account+".enc")
}
func TestLoadTokenDataFallsBackToLegacyOnlyWhenCurrentSlotIsMissing(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
data := testToken("at_fallback", "corp_fallback", "Fallback Org")
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
if err := DeleteTokenDataKeychainForCorpID(data.CorpID); err != nil {
t.Fatalf("DeleteTokenDataKeychainForCorpID() error = %v", err)
}
if err := preflightTokenPersistence(configDir); err != nil {
t.Fatalf("preflightTokenPersistence() with missing profile slot error = %v", err)
}
loaded, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loaded.AccessToken != data.AccessToken {
t.Fatalf("fallback access token = %q, want %q", loaded.AccessToken, data.AccessToken)
}
}
func TestLoadTokenDataDoesNotHideUnreadableCurrentSlotWithLegacyFallback(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
data := testToken("at_unreadable", "corp_unreadable", "Unreadable Org")
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
}
loaded, err := LoadTokenData(configDir)
if err == nil {
t.Fatalf("LoadTokenData() = %#v, nil; want unreadable profile error", loaded)
}
if loaded != nil {
t.Fatalf("LoadTokenData() data = %#v, want nil", loaded)
}
}
func TestPreflightTokenPersistenceAllowsEmptyStorageWithoutCreatingDEK(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
if err := preflightTokenPersistence(configDir); err != nil {
t.Fatalf("preflightTokenPersistence() error = %v", err)
}
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
t.Fatalf("preflight created a DEK at %q; stat error = %v", dekPath, err)
}
}
func TestPreflightTokenPersistenceRejectsUnreadableProfileSlot(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
data := testToken("at_preflight", "corp_preflight", "Preflight Org")
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
}
err := preflightTokenPersistence(configDir)
if err == nil || !strings.Contains(err.Error(), "profile token slot") {
t.Fatalf("preflightTokenPersistence() error = %v, want unreadable profile slot", err)
}
if !strings.Contains(err.Error(), "dws auth logout --profile \""+data.CorpID+"\"") {
t.Fatalf("preflightTokenPersistence() error = %v, want per-profile recovery hint", err)
}
}
func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
setPreflightTestCredentials(t)
configDir := t.TempDir()
data := testToken("at_orphan", "corp_orphan", "Orphan Org")
// Simulate interruption after the profile ciphertext rename but before
// profiles.json is updated by saveTokenDataLocked.
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
}
if _, err := os.Stat(filepath.Join(configDir, profilesJSONFile)); !os.IsNotExist(err) {
t.Fatalf("profiles.json stat error = %v, want missing metadata", err)
}
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x6f}, 32), 0o600); err != nil {
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
}
var calls atomic.Int32
provider := NewOAuthProvider(configDir, nil)
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
return nil, errors.New("unexpected HTTP request")
})}
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
if err == nil || !strings.Contains(err.Error(), "auth token ciphertext inventory") {
t.Fatalf("ExchangeAuthCode() error = %v, want orphan ciphertext preflight error", err)
}
if !keychain.IsCiphertextKeyMismatch(err) {
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("HTTP calls = %d, want 0", got)
}
}
func TestPortableAuthExportRejectsCiphertextFromAnotherDEK(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
data := testToken("at_portable", "", "")
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
if !PortableAuthSourceReady() {
t.Fatal("PortableAuthSourceReady() = false before replacing DEK")
}
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil {
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
}
if PortableAuthSourceReady() {
t.Fatal("PortableAuthSourceReady() = true for ciphertext from another DEK")
}
var bundle bytes.Buffer
if err := ExportPortableAuthBundle(configDir, &bundle); err == nil {
t.Fatal("ExportPortableAuthBundle() error = nil for ciphertext from another DEK")
}
if bundle.Len() != 0 {
t.Fatalf("ExportPortableAuthBundle() wrote %d bytes, want 0", bundle.Len())
}
}
func TestRefreshPreflightIgnoresUnreadableUnrelatedProfile(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
dataA := testToken("at_a", "corp_a", "A Org")
dataB := testToken("at_b", "corp_b", "B Org")
if err := SaveTokenData(configDir, dataA); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(configDir, dataB); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
if err := os.WriteFile(profileCiphertextPathForTest(dataA.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
t.Fatalf("WriteFile(A profile ciphertext) error = %v", err)
}
if err := preflightTokenRefreshPersistence(dataB); err != nil {
t.Fatalf("preflightTokenRefreshPersistence(B) error = %v", err)
}
loaded, err := NewOAuthProvider(configDir, nil).Login(context.Background(), false)
if err != nil {
t.Fatalf("Login() with valid B and unreadable A error = %v", err)
}
if loaded.AccessToken != dataB.AccessToken {
t.Fatalf("Login() access token = %q, want %q", loaded.AccessToken, dataB.AccessToken)
}
}
func TestOAuthLoginPreflightsTokenPersistence(t *testing.T) {
setPreflightTestCredentials(t)
for _, force := range []bool{false, true} {
t.Run("force="+map[bool]string{false: "false", true: "true"}[force], func(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
seedUnreadableTokenStorage(t, configDir, testToken("at_login", "corp_login", "Login Org"))
ctx, cancel := context.WithCancel(context.Background())
cancel()
provider := NewOAuthProvider(configDir, nil)
provider.NoBrowser = true
_, err := provider.Login(ctx, force)
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
t.Fatalf("Login(force=%v) error = %v, want token persistence preflight error", force, err)
}
})
}
}
func TestExchangeAuthCodePreflightsBeforeHTTP(t *testing.T) {
cleanupKeychain(t)
setPreflightTestCredentials(t)
configDir := t.TempDir()
seedUnreadableTokenStorage(t, configDir, testToken("at_exchange", "corp_exchange", "Exchange Org"))
var calls atomic.Int32
provider := NewOAuthProvider(configDir, nil)
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
return nil, errors.New("unexpected HTTP request")
})}
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
t.Fatalf("ExchangeAuthCode() error = %v, want token persistence preflight error", err)
}
if !keychain.IsCiphertextKeyMismatch(err) {
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("HTTP calls = %d, want 0", got)
}
}
func TestDeviceFlowLoginPreflightsBeforeDeviceCodeRequest(t *testing.T) {
cleanupKeychain(t)
setPreflightTestCredentials(t)
configDir := t.TempDir()
seedUnreadableTokenStorage(t, configDir, testToken("at_device", "corp_device", "Device Org"))
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
http.Error(w, "unexpected device code request", http.StatusInternalServerError)
}))
defer server.Close()
provider := NewDeviceFlowProvider(configDir, nil)
provider.Output = io.Discard
provider.SetBaseURL(server.URL)
_, err := provider.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
t.Fatalf("DeviceFlowProvider.Login() error = %v, want token persistence preflight error", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("device code requests = %d, want 0", got)
}
}
func TestLockedRefreshPreflightsLegacyMirrorBeforeHTTP(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
setPreflightTestCredentials(t)
configDir := t.TempDir()
data := testToken("at_refresh", "corp_refresh", "Refresh Org")
data.ExpiresAt = time.Now().Add(-time.Hour)
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
legacyPath := filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")
if err := os.WriteFile(legacyPath, []byte("corrupt legacy ciphertext"), 0o600); err != nil {
t.Fatalf("WriteFile(legacy ciphertext) error = %v", err)
}
var calls atomic.Int32
provider := NewOAuthProvider(configDir, nil)
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
return nil, errors.New("unexpected refresh request")
})}
_, err := provider.lockedRefresh(context.Background())
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
t.Fatalf("lockedRefresh() error = %v, want token persistence preflight error", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("refresh HTTP calls = %d, want 0", got)
}
}
func TestExchangeAuthCodeAllowsFirstLogin(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
setPreflightTestCredentials(t)
configDir := t.TempDir()
var calls atomic.Int32
provider := NewOAuthProvider(configDir, nil)
provider.Output = io.Discard
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(
`{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp_new"}`,
)),
}, nil
})}
data, err := provider.ExchangeAuthCode(context.Background(), "new-code", "user-new")
if err != nil {
t.Fatalf("ExchangeAuthCode() error = %v", err)
}
if data.AccessToken != "new-access" || data.UserID != "user-new" {
t.Fatalf("ExchangeAuthCode() data = %#v", data)
}
if got := calls.Load(); got != 1 {
t.Fatalf("HTTP calls = %d, want 1", got)
}
}