570 lines
21 KiB
Go
570 lines
21 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"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"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 TestLoadTokenDataUsesIdentitySlotWhenOrganizationMirrorIsUnreadable(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() error = %v", err)
|
|
}
|
|
if loaded == nil || loaded.AccessToken != data.AccessToken || loaded.UserID != data.UserID {
|
|
t.Fatalf("LoadTokenData() = %#v, want identity token %#v", loaded, data)
|
|
}
|
|
}
|
|
|
|
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 TestExactOrgCurrentRefreshRejectsUnreadableOrgMirror(t *testing.T) {
|
|
cleanupKeychain(t)
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
configDir := t.TempDir()
|
|
data := testToken("at_exact_refresh", "corp_exact", "Exact Org")
|
|
data.UserID = "user_exact"
|
|
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)
|
|
}
|
|
|
|
SetRuntimeProfile("corp_exact:user_exact")
|
|
defer SetRuntimeProfile("")
|
|
if err := preflightTokenRefreshPersistence(configDir, data); err == nil ||
|
|
!strings.Contains(err.Error(), "profile token slot") {
|
|
t.Fatalf("preflightTokenRefreshPersistence(exact current) error = %v, want unreadable org mirror", err)
|
|
}
|
|
}
|
|
|
|
func TestExactNonOrgCurrentRefreshIgnoresUnreadableOrgMirror(t *testing.T) {
|
|
cleanupKeychain(t)
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
configDir := t.TempDir()
|
|
first := testToken("at_first", "corp_exact", "Exact Org")
|
|
first.UserID = "user_first"
|
|
second := testToken("at_second", "corp_exact", "Exact Org")
|
|
second.UserID = "user_second"
|
|
if err := SaveTokenData(configDir, first); err != nil {
|
|
t.Fatalf("SaveTokenData(first) error = %v", err)
|
|
}
|
|
if err := SaveTokenData(configDir, second); err != nil {
|
|
t.Fatalf("SaveTokenData(second) error = %v", err)
|
|
}
|
|
if err := os.WriteFile(profileCiphertextPathForTest(first.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
|
|
}
|
|
|
|
SetRuntimeProfile("corp_exact:user_first")
|
|
defer SetRuntimeProfile("")
|
|
if err := preflightTokenRefreshPersistence(configDir, first); err != nil {
|
|
t.Fatalf("preflightTokenRefreshPersistence(exact non-current) error = %v", err)
|
|
}
|
|
updated := *first
|
|
updated.AccessToken = "at_first_refreshed"
|
|
updated.RefreshToken = "rt_first_refreshed"
|
|
if err := SaveTokenData(configDir, &updated); err != nil {
|
|
t.Fatalf("SaveTokenData(exact non-current refresh) error = %v", err)
|
|
}
|
|
loaded, err := LoadTokenDataForProfile(configDir, "corp_exact:user_first")
|
|
if err != nil {
|
|
t.Fatalf("LoadTokenDataForProfile(refreshed) error = %v", err)
|
|
}
|
|
if loaded.AccessToken != updated.AccessToken || loaded.RefreshToken != updated.RefreshToken {
|
|
t.Fatalf("refreshed exact token = %#v, want %#v", loaded, updated)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageExactRefreshAndSwitchIgnoreUnreadableReservedBlankOrgSlot(t *testing.T) {
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
fixture := seedLegacyBlankAndExactIdentitySlots(t)
|
|
if err := os.WriteFile(profileCiphertextPathForTest(fixture.corpID), []byte("corrupt reserved blank slot"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(reserved blank ciphertext) error = %v", err)
|
|
}
|
|
|
|
SetRuntimeProfile("")
|
|
if err := preflightTokenRefreshPersistence(fixture.configDir, fixture.beta); err != nil {
|
|
t.Fatalf("preflightTokenRefreshPersistence(current exact with reserved blank) error = %v", err)
|
|
}
|
|
refreshed := *fixture.beta
|
|
refreshed.AccessToken = "at_identity_beta_refreshed"
|
|
if err := SaveTokenData(fixture.configDir, &refreshed); err != nil {
|
|
t.Fatalf("SaveTokenData(current exact with reserved blank) error = %v", err)
|
|
}
|
|
if raw, err := os.ReadFile(profileCiphertextPathForTest(fixture.corpID)); err != nil || string(raw) != "corrupt reserved blank slot" {
|
|
t.Fatalf("reserved blank slot changed during exact refresh: %q, %v", raw, err)
|
|
}
|
|
|
|
if selected, err := SetCurrentProfile(fixture.configDir, profileSelector(fixture.alpha.CorpID, fixture.alpha.UserID)); err != nil || selected.UserID != fixture.alpha.UserID {
|
|
t.Fatalf("SetCurrentProfile(exact with reserved blank) = %#v, %v", selected, err)
|
|
}
|
|
if selected, err := UsePreviousProfile(fixture.configDir); err != nil || selected.UserID != fixture.beta.UserID {
|
|
t.Fatalf("UsePreviousProfile(exact with reserved blank) = %#v, %v", selected, err)
|
|
}
|
|
if raw, err := os.ReadFile(profileCiphertextPathForTest(fixture.corpID)); err != nil || string(raw) != "corrupt reserved blank slot" {
|
|
t.Fatalf("reserved blank slot changed during exact switches: %q, %v", raw, err)
|
|
}
|
|
|
|
blankRefresh := *fixture.blank
|
|
blankRefresh.LegacyOrgScopedProfile = fixture.blankName
|
|
SetRuntimeProfile(fixture.blankName)
|
|
if err := preflightTokenRefreshPersistence(fixture.configDir, &blankRefresh); err == nil ||
|
|
!strings.Contains(err.Error(), "profile token slot") {
|
|
t.Fatalf("blank refresh preflight error = %v, want unreadable reserved slot", err)
|
|
}
|
|
}
|
|
|
|
func TestFullTokenPersistenceInventoryDetectsOrphanProfileCiphertext(t *testing.T) {
|
|
cleanupKeychain(t)
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
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)
|
|
}
|
|
|
|
err := preflightTokenPersistence(configDir)
|
|
if err == nil || !strings.Contains(err.Error(), "auth token ciphertext inventory") {
|
|
t.Fatalf("preflightTokenPersistence() error = %v, want orphan ciphertext inventory error", err)
|
|
}
|
|
if !keychain.IsCiphertextKeyMismatch(err) {
|
|
t.Fatalf("preflightTokenPersistence() error = %v, want ciphertext key mismatch in error chain", err)
|
|
}
|
|
}
|
|
|
|
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(configDir, 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 TestOAuthLoginUnreadableGlobalFailsClosedBeforeAuthorizationStart(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"))
|
|
|
|
listenErr := errors.New("authorization listener reached")
|
|
var calls atomic.Int32
|
|
oldListen := oauthListen
|
|
oauthListen = func(string, string) (net.Listener, error) {
|
|
calls.Add(1)
|
|
return nil, listenErr
|
|
}
|
|
t.Cleanup(func() { oauthListen = oldListen })
|
|
|
|
provider := NewOAuthProvider(configDir, nil)
|
|
provider.NoBrowser = true
|
|
_, err := provider.Login(context.Background(), force)
|
|
if err == nil || !strings.Contains(err.Error(), "refusing to overwrite") {
|
|
t.Fatalf("Login(force=%v) error = %v, want unreadable-global protection", force, err)
|
|
}
|
|
if errors.Is(err, listenErr) {
|
|
t.Fatalf("Login(force=%v) reached authorization listener: %v", force, err)
|
|
}
|
|
if got := calls.Load(); got != 0 {
|
|
t.Fatalf("Login(force=%v) listener calls = %d, want 0", force, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExchangeAuthCodeRejectsUnreadableGlobalBeforeHTTP(t *testing.T) {
|
|
cleanupKeychain(t)
|
|
setPreflightTestCredentials(t)
|
|
configDir := t.TempDir()
|
|
existing := testToken("at_exchange", "corp_exchange", "Exchange Org")
|
|
seedUnreadableTokenStorage(t, configDir, existing)
|
|
|
|
var calls atomic.Int32
|
|
var saveCalls atomic.Int32
|
|
oldSave := oauthSaveToken
|
|
oauthSaveToken = func(string, *TokenData) error {
|
|
saveCalls.Add(1)
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { oauthSaveToken = oldSave })
|
|
provider := NewOAuthProvider(configDir, nil)
|
|
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_exchange"}`,
|
|
)),
|
|
}, nil
|
|
})}
|
|
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", existing.UserID)
|
|
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
|
t.Fatalf("ExchangeAuthCode() error = %v, want target token persistence 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)
|
|
}
|
|
if got := saveCalls.Load(); got != 0 {
|
|
t.Fatalf("SaveTokenData calls = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestDeviceFlowLoginRejectsUnreadableGlobalBeforeDeviceCodeRequest(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 unreadable-global protection", 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 TestLockedRefreshRejectsFutureProfilesVersionBeforeHTTP(t *testing.T) {
|
|
cleanupKeychain(t)
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
setPreflightTestCredentials(t)
|
|
configDir := t.TempDir()
|
|
data := testToken("at_future_refresh", "corp_future", "Future Org")
|
|
data.ExpiresAt = time.Now().Add(-time.Hour)
|
|
data.RefreshExpAt = time.Now().Add(time.Hour)
|
|
if err := SaveTokenData(configDir, data); err != nil {
|
|
t.Fatalf("SaveTokenData() error = %v", err)
|
|
}
|
|
cfg, err := LoadProfiles(configDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadProfiles() error = %v", err)
|
|
}
|
|
cfg.Version = profilesMaxVersion + 1
|
|
raw, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(ProfilesPath(configDir), raw, 0o600); err != nil {
|
|
t.Fatalf("write future profiles: %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.Login(context.Background(), false)
|
|
if err == nil || !strings.Contains(err.Error(), "newer than supported") {
|
|
t.Fatalf("Login() error = %v, want future profiles rejection", 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)
|
|
}
|
|
}
|
|
|
|
func TestExchangeAuthCodeExplicitUIDSkipsIdentityOverride(t *testing.T) {
|
|
cleanupKeychain(t)
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
setPreflightTestCredentials(t)
|
|
configDir := t.TempDir()
|
|
|
|
var identityCalls atomic.Int32
|
|
provider := NewOAuthProvider(configDir, nil)
|
|
provider.IdentityEnricher = func(context.Context, *TokenData) error {
|
|
identityCalls.Add(1)
|
|
return errors.New("identity lookup should not run for explicit uid")
|
|
}
|
|
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
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", "explicit-user")
|
|
if err != nil {
|
|
t.Fatalf("ExchangeAuthCode() error = %v", err)
|
|
}
|
|
if data.UserID != "explicit-user" {
|
|
t.Fatalf("ExchangeAuthCode() userId = %q, want explicit-user", data.UserID)
|
|
}
|
|
if got := identityCalls.Load(); got != 0 {
|
|
t.Fatalf("IdentityEnricher calls = %d, want 0", got)
|
|
}
|
|
}
|