mirror of
https://github.com/ollama/ollama.git
synced 2026-01-19 21:08:16 -05:00
Compare commits
5 Commits
parth/decr
...
parth/sign
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b978dc28d2 | ||
|
|
106640b978 | ||
|
|
f4e5978e4f | ||
|
|
528de77b4b | ||
|
|
eed58a312d |
86
auth/auth.go
86
auth/auth.go
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -12,11 +13,22 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultPrivateKey = "id_ed25519"
|
const (
|
||||||
|
defaultPrivateKey = "id_ed25519"
|
||||||
|
signInStateFile = "signin.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SignInState represents the locally cached sign-in state
|
||||||
|
type SignInState struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
CachedAt time.Time `json:"cached_at"`
|
||||||
|
}
|
||||||
|
|
||||||
func GetPublicKey() (string, error) {
|
func GetPublicKey() (string, error) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
@@ -83,3 +95,75 @@ func Sign(ctx context.Context, bts []byte) (string, error) {
|
|||||||
// signature is <pubkey>:<signature>
|
// signature is <pubkey>:<signature>
|
||||||
return fmt.Sprintf("%s:%s", bytes.TrimSpace(parts[1]), base64.StdEncoding.EncodeToString(signedData.Blob)), nil
|
return fmt.Sprintf("%s:%s", bytes.TrimSpace(parts[1]), base64.StdEncoding.EncodeToString(signedData.Blob)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSignInState reads the locally cached sign-in state from ~/.ollama/signin.json
|
||||||
|
func GetSignInState() (*SignInState, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
statePath := filepath.Join(home, ".ollama", signInStateFile)
|
||||||
|
data, err := os.ReadFile(statePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var state SignInState
|
||||||
|
if err := json.Unmarshal(data, &state); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSignInState atomically writes the sign-in state to ~/.ollama/signin.json
|
||||||
|
func SetSignInState(state *SignInState) error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ollamaDir := filepath.Join(home, ".ollama")
|
||||||
|
statePath := filepath.Join(ollamaDir, signInStateFile)
|
||||||
|
tmpPath := statePath + ".tmp"
|
||||||
|
|
||||||
|
state.CachedAt = time.Now()
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(state, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to temp file first
|
||||||
|
if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic rename
|
||||||
|
return os.Rename(tmpPath, statePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSignInState removes the locally cached sign-in state
|
||||||
|
func ClearSignInState() error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
statePath := filepath.Join(home, ".ollama", signInStateFile)
|
||||||
|
err = os.Remove(statePath)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil // Already cleared
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSignedIn returns true if there is a valid locally cached sign-in state
|
||||||
|
func IsSignedIn() bool {
|
||||||
|
state, err := GetSignInState()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return state.Name != ""
|
||||||
|
}
|
||||||
|
|||||||
294
auth/signin_flow_test.go
Normal file
294
auth/signin_flow_test.go
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWhoamiHandlerFlow simulates the WhoamiHandler logic flow
|
||||||
|
func TestWhoamiHandlerFlow(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
// Scenario 1: No local cache - should indicate need for network call
|
||||||
|
t.Run("NoCache_RequiresNetwork", func(t *testing.T) {
|
||||||
|
state, err := GetSignInState()
|
||||||
|
if err == nil && state != nil && state.Name != "" {
|
||||||
|
t.Error("should not have cached state initially")
|
||||||
|
}
|
||||||
|
// In real WhoamiHandler, this would trigger a network call
|
||||||
|
})
|
||||||
|
|
||||||
|
// Scenario 2: Simulate successful sign-in from network
|
||||||
|
t.Run("CacheAfterNetworkSuccess", func(t *testing.T) {
|
||||||
|
// Simulate receiving user from ollama.com
|
||||||
|
networkUser := &SignInState{
|
||||||
|
Name: "networkuser",
|
||||||
|
Email: "network@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result (as WhoamiHandler would)
|
||||||
|
if err := SetSignInState(networkUser); err != nil {
|
||||||
|
t.Fatalf("SetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's cached
|
||||||
|
if !IsSignedIn() {
|
||||||
|
t.Error("should be signed in after caching")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Scenario 3: Subsequent calls use cache (no network)
|
||||||
|
t.Run("SubsequentCalls_UseCache", func(t *testing.T) {
|
||||||
|
state, err := GetSignInState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.Name != "networkuser" {
|
||||||
|
t.Errorf("expected cached name 'networkuser', got '%s'", state.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In real WhoamiHandler, this would skip the network call and return cached data
|
||||||
|
})
|
||||||
|
|
||||||
|
// Scenario 4: Sign-out clears cache
|
||||||
|
t.Run("SignOut_ClearsCache", func(t *testing.T) {
|
||||||
|
// Simulate SignoutHandler clearing cache
|
||||||
|
if err := ClearSignInState(); err != nil {
|
||||||
|
t.Fatalf("ClearSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if IsSignedIn() {
|
||||||
|
t.Error("should not be signed in after sign-out")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Scenario 5: After sign-out, next call requires network
|
||||||
|
t.Run("AfterSignOut_RequiresNetwork", func(t *testing.T) {
|
||||||
|
state, err := GetSignInState()
|
||||||
|
if err == nil && state != nil && state.Name != "" {
|
||||||
|
t.Error("should require network after sign-out")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOfflineScenarios tests behavior when offline
|
||||||
|
func TestOfflineScenarios(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
t.Run("Offline_WithCache_Works", func(t *testing.T) {
|
||||||
|
// Pre-populate cache (simulate previous sign-in)
|
||||||
|
state := &SignInState{Name: "cacheduser", Email: "cached@example.com"}
|
||||||
|
if err := SetSignInState(state); err != nil {
|
||||||
|
t.Fatalf("SetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate offline check - should work with cache
|
||||||
|
cached, err := GetSignInState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("should work offline with cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cached.Name != "cacheduser" {
|
||||||
|
t.Errorf("expected 'cacheduser', got '%s'", cached.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !IsSignedIn() {
|
||||||
|
t.Error("should report signed in with cache")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Offline_WithoutCache_Fails", func(t *testing.T) {
|
||||||
|
// Clear cache first
|
||||||
|
ClearSignInState()
|
||||||
|
|
||||||
|
// Offline without cache should indicate not signed in
|
||||||
|
if IsSignedIn() {
|
||||||
|
t.Error("should not be signed in offline without cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := GetSignInState()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("should get error when no cache exists")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMultipleSessions tests overwriting sessions
|
||||||
|
func TestMultipleSessions(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
t.Run("NewSignIn_OverwritesOld", func(t *testing.T) {
|
||||||
|
// First user signs in
|
||||||
|
user1 := &SignInState{Name: "user1", Email: "user1@example.com"}
|
||||||
|
SetSignInState(user1)
|
||||||
|
|
||||||
|
// Different user signs in (should overwrite)
|
||||||
|
user2 := &SignInState{Name: "user2", Email: "user2@example.com"}
|
||||||
|
SetSignInState(user2)
|
||||||
|
|
||||||
|
// Should have user2's data
|
||||||
|
state, _ := GetSignInState()
|
||||||
|
if state.Name != "user2" {
|
||||||
|
t.Errorf("expected 'user2', got '%s'", state.Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEdgeCases tests various edge cases
|
||||||
|
func TestEdgeCases(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
t.Run("EmptyName_NotSignedIn", func(t *testing.T) {
|
||||||
|
// User with empty name should not count as signed in
|
||||||
|
state := &SignInState{Name: "", Email: "noname@example.com"}
|
||||||
|
SetSignInState(state)
|
||||||
|
|
||||||
|
if IsSignedIn() {
|
||||||
|
t.Error("empty name should not count as signed in")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SpecialCharactersInName", func(t *testing.T) {
|
||||||
|
state := &SignInState{
|
||||||
|
Name: "user with spaces & symbols!@#$%",
|
||||||
|
Email: "special@example.com",
|
||||||
|
}
|
||||||
|
if err := SetSignInState(state); err != nil {
|
||||||
|
t.Fatalf("failed to set state with special chars: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
read, err := GetSignInState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read state: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if read.Name != state.Name {
|
||||||
|
t.Errorf("name mismatch with special chars")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UnicodeInName", func(t *testing.T) {
|
||||||
|
state := &SignInState{
|
||||||
|
Name: "用户名 🎉 émojis",
|
||||||
|
Email: "unicode@example.com",
|
||||||
|
}
|
||||||
|
if err := SetSignInState(state); err != nil {
|
||||||
|
t.Fatalf("failed to set state with unicode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
read, err := GetSignInState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read state: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if read.Name != state.Name {
|
||||||
|
t.Errorf("name mismatch with unicode")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("VeryLongEmail", func(t *testing.T) {
|
||||||
|
longEmail := ""
|
||||||
|
for range 1000 {
|
||||||
|
longEmail += "a"
|
||||||
|
}
|
||||||
|
longEmail += "@example.com"
|
||||||
|
|
||||||
|
state := &SignInState{Name: "user", Email: longEmail}
|
||||||
|
if err := SetSignInState(state); err != nil {
|
||||||
|
t.Fatalf("failed to set state with long email: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
read, err := GetSignInState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read state: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if read.Email != longEmail {
|
||||||
|
t.Error("email mismatch with long value")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentAccess tests race conditions
|
||||||
|
func TestConcurrentAccess(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
t.Run("ConcurrentReads", func(t *testing.T) {
|
||||||
|
// Set up initial state
|
||||||
|
state := &SignInState{Name: "concurrent", Email: "concurrent@example.com"}
|
||||||
|
SetSignInState(state)
|
||||||
|
|
||||||
|
// Multiple concurrent reads should all succeed
|
||||||
|
done := make(chan bool, 10)
|
||||||
|
for range 10 {
|
||||||
|
go func() {
|
||||||
|
read, err := GetSignInState()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("concurrent read failed: %v", err)
|
||||||
|
}
|
||||||
|
if read.Name != "concurrent" {
|
||||||
|
t.Errorf("wrong name in concurrent read")
|
||||||
|
}
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
for range 10 {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ConcurrentWrites", func(t *testing.T) {
|
||||||
|
// Multiple concurrent writes - last one should win
|
||||||
|
done := make(chan bool, 10)
|
||||||
|
for range 10 {
|
||||||
|
go func() {
|
||||||
|
state := &SignInState{
|
||||||
|
Name: "user",
|
||||||
|
Email: "user@example.com",
|
||||||
|
}
|
||||||
|
SetSignInState(state)
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
for range 10 {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have some valid state
|
||||||
|
if !IsSignedIn() {
|
||||||
|
t.Error("should be signed in after concurrent writes")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileSystemEdgeCases tests filesystem-related edge cases
|
||||||
|
func TestFileSystemEdgeCases(t *testing.T) {
|
||||||
|
t.Run("DirectoryIsFile", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a file where .ollama directory should be
|
||||||
|
ollamaPath := filepath.Join(tmpDir, ".ollama")
|
||||||
|
if err := os.WriteFile(ollamaPath, []byte("not a directory"), 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to create blocking file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override home directory
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Setenv("USERPROFILE", tmpDir)
|
||||||
|
} else {
|
||||||
|
t.Setenv("HOME", tmpDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to write - should fail because .ollama is a file, not a directory
|
||||||
|
state := &SignInState{Name: "newuser", Email: "new@example.com"}
|
||||||
|
err := SetSignInState(state)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("should fail when .ollama is a file instead of directory")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
347
auth/signin_state_test.go
Normal file
347
auth/signin_state_test.go
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create a temporary directory for testing
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create .ollama subdirectory
|
||||||
|
ollamaDir := filepath.Join(tmpDir, ".ollama")
|
||||||
|
if err := os.MkdirAll(ollamaDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create .ollama dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override home directory for tests (platform-specific)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Setenv("USERPROFILE", tmpDir)
|
||||||
|
} else {
|
||||||
|
t.Setenv("HOME", tmpDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetSignInState(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
state := &SignInState{
|
||||||
|
Name: "testuser",
|
||||||
|
Email: "test@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := SetSignInState(state)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file was created
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
statePath := filepath.Join(home, ".ollama", signInStateFile)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(statePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read state file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedState SignInState
|
||||||
|
if err := json.Unmarshal(data, &savedState); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal state: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if savedState.Name != "testuser" {
|
||||||
|
t.Errorf("expected name 'testuser', got '%s'", savedState.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if savedState.Email != "test@example.com" {
|
||||||
|
t.Errorf("expected email 'test@example.com', got '%s'", savedState.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
if savedState.CachedAt.IsZero() {
|
||||||
|
t.Error("expected CachedAt to be set, got zero time")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify CachedAt is recent (within last minute)
|
||||||
|
if time.Since(savedState.CachedAt) > time.Minute {
|
||||||
|
t.Errorf("CachedAt is too old: %v", savedState.CachedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetSignInState_Overwrites(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
// Set initial state
|
||||||
|
state1 := &SignInState{Name: "user1", Email: "user1@example.com"}
|
||||||
|
if err := SetSignInState(state1); err != nil {
|
||||||
|
t.Fatalf("first SetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrite with new state
|
||||||
|
state2 := &SignInState{Name: "user2", Email: "user2@example.com"}
|
||||||
|
if err := SetSignInState(state2); err != nil {
|
||||||
|
t.Fatalf("second SetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify only new state exists
|
||||||
|
readState, err := GetSignInState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if readState.Name != "user2" {
|
||||||
|
t.Errorf("expected name 'user2', got '%s'", readState.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSignInState(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
// First set a state
|
||||||
|
originalState := &SignInState{
|
||||||
|
Name: "testuser",
|
||||||
|
Email: "test@example.com",
|
||||||
|
}
|
||||||
|
if err := SetSignInState(originalState); err != nil {
|
||||||
|
t.Fatalf("SetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now read it back
|
||||||
|
readState, err := GetSignInState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if readState.Name != originalState.Name {
|
||||||
|
t.Errorf("expected name '%s', got '%s'", originalState.Name, readState.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if readState.Email != originalState.Email {
|
||||||
|
t.Errorf("expected email '%s', got '%s'", originalState.Email, readState.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSignInState_NoFile(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
// Try to read without any file existing
|
||||||
|
state, err := GetSignInState()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when file doesn't exist, got nil")
|
||||||
|
}
|
||||||
|
if state != nil {
|
||||||
|
t.Errorf("expected nil state, got %+v", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSignInState_InvalidJSON(t *testing.T) {
|
||||||
|
tmpDir := setupTestDir(t)
|
||||||
|
|
||||||
|
// Write invalid JSON to the state file
|
||||||
|
statePath := filepath.Join(tmpDir, ".ollama", signInStateFile)
|
||||||
|
if err := os.WriteFile(statePath, []byte("not valid json"), 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write invalid json: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := GetSignInState()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid JSON, got nil")
|
||||||
|
}
|
||||||
|
if state != nil {
|
||||||
|
t.Errorf("expected nil state for invalid JSON, got %+v", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearSignInState(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
// First set a state
|
||||||
|
state := &SignInState{Name: "testuser", Email: "test@example.com"}
|
||||||
|
if err := SetSignInState(state); err != nil {
|
||||||
|
t.Fatalf("SetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file exists
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
statePath := filepath.Join(home, ".ollama", signInStateFile)
|
||||||
|
if _, err := os.Stat(statePath); os.IsNotExist(err) {
|
||||||
|
t.Fatal("state file should exist before clearing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the state
|
||||||
|
if err := ClearSignInState(); err != nil {
|
||||||
|
t.Fatalf("ClearSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file is gone
|
||||||
|
if _, err := os.Stat(statePath); !os.IsNotExist(err) {
|
||||||
|
t.Error("state file should be deleted after clearing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearSignInState_NoFile(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
// Clear when no file exists should not error
|
||||||
|
err := ClearSignInState()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ClearSignInState should not error when file doesn't exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearSignInState_Idempotent(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
// Set a state first
|
||||||
|
state := &SignInState{Name: "testuser", Email: "test@example.com"}
|
||||||
|
if err := SetSignInState(state); err != nil {
|
||||||
|
t.Fatalf("SetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear multiple times should not error
|
||||||
|
for i := range 3 {
|
||||||
|
if err := ClearSignInState(); err != nil {
|
||||||
|
t.Errorf("ClearSignInState iteration %d failed: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSignedIn(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
// Initially not signed in
|
||||||
|
if IsSignedIn() {
|
||||||
|
t.Error("should not be signed in initially")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a state with a name
|
||||||
|
state := &SignInState{Name: "testuser", Email: "test@example.com"}
|
||||||
|
if err := SetSignInState(state); err != nil {
|
||||||
|
t.Fatalf("SetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now should be signed in
|
||||||
|
if !IsSignedIn() {
|
||||||
|
t.Error("should be signed in after setting state")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the state
|
||||||
|
if err := ClearSignInState(); err != nil {
|
||||||
|
t.Fatalf("ClearSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not be signed in after clearing
|
||||||
|
if IsSignedIn() {
|
||||||
|
t.Error("should not be signed in after clearing state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSignedIn_EmptyName(t *testing.T) {
|
||||||
|
tmpDir := setupTestDir(t)
|
||||||
|
|
||||||
|
// Write a state with empty name directly
|
||||||
|
state := SignInState{
|
||||||
|
Name: "",
|
||||||
|
Email: "test@example.com",
|
||||||
|
CachedAt: time.Now(),
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(state)
|
||||||
|
statePath := filepath.Join(tmpDir, ".ollama", signInStateFile)
|
||||||
|
if err := os.WriteFile(statePath, data, 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write state: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not be signed in with empty name
|
||||||
|
if IsSignedIn() {
|
||||||
|
t.Error("should not be signed in with empty name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetSignInState_AtomicWrite(t *testing.T) {
|
||||||
|
tmpDir := setupTestDir(t)
|
||||||
|
|
||||||
|
state := &SignInState{Name: "testuser", Email: "test@example.com"}
|
||||||
|
if err := SetSignInState(state); err != nil {
|
||||||
|
t.Fatalf("SetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify temp file is cleaned up
|
||||||
|
tmpPath := filepath.Join(tmpDir, ".ollama", signInStateFile+".tmp")
|
||||||
|
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
|
||||||
|
t.Error("temp file should be cleaned up after atomic write")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify final file exists
|
||||||
|
statePath := filepath.Join(tmpDir, ".ollama", signInStateFile)
|
||||||
|
if _, err := os.Stat(statePath); os.IsNotExist(err) {
|
||||||
|
t.Error("final state file should exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetSignInState_FilePermissions(t *testing.T) {
|
||||||
|
tmpDir := setupTestDir(t)
|
||||||
|
|
||||||
|
state := &SignInState{Name: "testuser", Email: "test@example.com"}
|
||||||
|
if err := SetSignInState(state); err != nil {
|
||||||
|
t.Fatalf("SetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statePath := filepath.Join(tmpDir, ".ollama", signInStateFile)
|
||||||
|
info, err := os.Stat(statePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to stat state file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file permissions (should be 0600 - owner read/write only)
|
||||||
|
perm := info.Mode().Perm()
|
||||||
|
if perm != 0o600 {
|
||||||
|
t.Errorf("expected permissions 0600, got %04o", perm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundTrip(t *testing.T) {
|
||||||
|
_ = setupTestDir(t)
|
||||||
|
|
||||||
|
// Test full round trip: set -> get -> clear -> get
|
||||||
|
original := &SignInState{
|
||||||
|
Name: "roundtrip_user",
|
||||||
|
Email: "roundtrip@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set
|
||||||
|
if err := SetSignInState(original); err != nil {
|
||||||
|
t.Fatalf("SetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get and verify
|
||||||
|
retrieved, err := GetSignInState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Name != original.Name {
|
||||||
|
t.Errorf("name mismatch: expected '%s', got '%s'", original.Name, retrieved.Name)
|
||||||
|
}
|
||||||
|
if retrieved.Email != original.Email {
|
||||||
|
t.Errorf("email mismatch: expected '%s', got '%s'", original.Email, retrieved.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
if err := ClearSignInState(); err != nil {
|
||||||
|
t.Fatalf("ClearSignInState failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get should fail now
|
||||||
|
_, err = GetSignInState()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("GetSignInState should fail after clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1745,6 +1745,14 @@ func streamResponse(c *gin.Context, ch chan any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) WhoamiHandler(c *gin.Context) {
|
func (s *Server) WhoamiHandler(c *gin.Context) {
|
||||||
|
// Check local cache first
|
||||||
|
state, err := auth.GetSignInState()
|
||||||
|
if err == nil && state.Name != "" {
|
||||||
|
c.JSON(http.StatusOK, &api.UserResponse{Name: state.Name, Email: state.Email})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No local cache - try network
|
||||||
// todo allow other hosts
|
// todo allow other hosts
|
||||||
u, err := url.Parse("https://ollama.com")
|
u, err := url.Parse("https://ollama.com")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1772,6 +1780,13 @@ func (s *Server) WhoamiHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache successful result locally
|
||||||
|
if user != nil && user.Name != "" {
|
||||||
|
if err := auth.SetSignInState(&auth.SignInState{Name: user.Name, Email: user.Email}); err != nil {
|
||||||
|
slog.Warn("failed to cache sign-in state", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, user)
|
c.JSON(http.StatusOK, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1805,6 +1820,11 @@ func (s *Server) SignoutHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear local sign-in cache
|
||||||
|
if err := auth.ClearSignInState(); err != nil {
|
||||||
|
slog.Warn("failed to clear local sign-in state", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, nil)
|
c.JSON(http.StatusOK, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user