fix: align in-memory authentication semantics

This commit is contained in:
2026-07-22 16:27:24 +08:00
parent a174a22bcb
commit 25c4fbe089
2 changed files with 71 additions and 2 deletions
+8 -2
View File
@@ -106,6 +106,9 @@ func NewClientWithAuthData(authData AuthData, options ...ClientOption) (*Client,
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !authData.zero() {
authData = client.authDataWithIdentity(authData)
}
client.setAuthData(authData) client.setAuthData(authData)
return client, nil return client, nil
} }
@@ -131,8 +134,11 @@ func newClient(options ...ClientOption) (*Client, error) {
return client, nil return client, nil
} }
// WithAuthDataChanged configures serialized persistence for in-memory auth updates. // WithAuthDataChanged configures synchronous persistence for in-memory auth updates.
// The callback runs under login serialization, but never while the auth data lock is held. // The callback is serialized by the client's login lock and may acquire unrelated
// application locks, but it must not call Client methods other than AuthData.
// In particular, calling Login or another method that may refresh authentication
// will deadlock. Returning an error leaves the client's authentication unchanged.
func WithAuthDataChanged(callback func(AuthData) error) ClientOption { func WithAuthDataChanged(callback func(AuthData) error) ClientOption {
return func(client *Client) error { return func(client *Client) error {
if callback == nil { if callback == nil {
+63
View File
@@ -10,6 +10,7 @@ import (
"path/filepath" "path/filepath"
"reflect" "reflect"
"strings" "strings"
"sync"
"testing" "testing"
) )
@@ -47,6 +48,31 @@ func TestNewClientWithAuthDataRejectsPartialData(t *testing.T) {
} }
} }
func TestNewClientWithAuthDataGeneratesMissingIdentityWithoutMutatingInput(t *testing.T) {
input := completeAuthData()
input.DeviceID = ""
input.PassO = ""
client, err := NewClientWithAuthData(input)
if err != nil {
t.Fatal(err)
}
if input.DeviceID != "" || input.PassO != "" {
t.Fatalf("caller auth data mutated: %#v", input)
}
stored := client.AuthData()
if stored.DeviceID == "" || stored.PassO == "" {
t.Fatalf("stored identity not generated: %#v", stored)
}
}
func TestWithAuthDataChangedRejectsNil(t *testing.T) {
_, err := NewClientWithAuthData(AuthData{}, WithAuthDataChanged(nil))
if err == nil || !strings.Contains(err.Error(), "must not be nil") {
t.Fatalf("error = %v, want nil callback error", err)
}
}
func TestNewClientWithAuthDataDoesNotAccessFilesystem(t *testing.T) { func TestNewClientWithAuthDataDoesNotAccessFilesystem(t *testing.T) {
home := filepath.Join(t.TempDir(), "must-not-exist") home := filepath.Join(t.TempDir(), "must-not-exist")
t.Setenv("HOME", home) t.Setenv("HOME", home)
@@ -110,6 +136,43 @@ func TestMemoryRefreshCallbackCanReadCurrentAuth(t *testing.T) {
} }
} }
func TestMemoryRefreshCallbackCanAcquireApplicationLock(t *testing.T) {
var persistenceMu sync.Mutex
client, _, server := newMemoryRefreshClient(t, func(AuthData) error {
persistenceMu.Lock()
defer persistenceMu.Unlock()
return nil
})
defer server.Close()
if err := client.refreshToken(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestMemoryRefreshPersistsGeneratedIdentity(t *testing.T) {
initial := completeAuthData()
initial.DeviceID = ""
initial.PassO = ""
var changed AuthData
client, err := NewClientWithAuthData(initial, WithAuthDataChanged(func(authData AuthData) error {
changed = authData.clone()
return nil
}))
if err != nil {
t.Fatal(err)
}
server := configureRefreshServer(t, client)
defer server.Close()
if err := client.refreshToken(context.Background()); err != nil {
t.Fatal(err)
}
if changed.DeviceID == "" || changed.PassO == "" {
t.Fatalf("persisted identity not generated: %#v", changed)
}
}
func TestFreshMemoryClientQRLoginPersistsThroughCallback(t *testing.T) { func TestFreshMemoryClientQRLoginPersistsThroughCallback(t *testing.T) {
var changed AuthData var changed AuthData
var server *httptest.Server var server *httptest.Server