403 lines
12 KiB
Go
403 lines
12 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
|
)
|
|
|
|
func TestExtractAccessToken(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
metadata map[string]any
|
|
expected string
|
|
}{
|
|
{
|
|
"antigravity top-level access_token",
|
|
map[string]any{"access_token": "tok-abc"},
|
|
"tok-abc",
|
|
},
|
|
{
|
|
"gemini nested token.access_token",
|
|
map[string]any{
|
|
"token": map[string]any{"access_token": "tok-nested"},
|
|
},
|
|
"tok-nested",
|
|
},
|
|
{
|
|
"top-level takes precedence over nested",
|
|
map[string]any{
|
|
"access_token": "tok-top",
|
|
"token": map[string]any{"access_token": "tok-nested"},
|
|
},
|
|
"tok-top",
|
|
},
|
|
{
|
|
"empty metadata",
|
|
map[string]any{},
|
|
"",
|
|
},
|
|
{
|
|
"whitespace-only access_token",
|
|
map[string]any{"access_token": " "},
|
|
"",
|
|
},
|
|
{
|
|
"wrong type access_token",
|
|
map[string]any{"access_token": 12345},
|
|
"",
|
|
},
|
|
{
|
|
"token is not a map",
|
|
map[string]any{"token": "not-a-map"},
|
|
"",
|
|
},
|
|
{
|
|
"nested whitespace-only",
|
|
map[string]any{
|
|
"token": map[string]any{"access_token": " "},
|
|
},
|
|
"",
|
|
},
|
|
{
|
|
"fallback to nested when top-level empty",
|
|
map[string]any{
|
|
"access_token": "",
|
|
"token": map[string]any{"access_token": "tok-fallback"},
|
|
},
|
|
"tok-fallback",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := extractAccessToken(tt.metadata)
|
|
if got != tt.expected {
|
|
t.Errorf("extractAccessToken() = %q, want %q", got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFileTokenStoreSaveExistingMetadataSetsFileAttributes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
existingToken string
|
|
savedToken string
|
|
}{
|
|
{name: "unchanged content", existingToken: "token", savedToken: "token"},
|
|
{name: "overwritten content", existingToken: "old-token", savedToken: "new-token"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
fileName := "antigravity-user.json"
|
|
path := filepath.Join(baseDir, fileName)
|
|
existing := []byte(`{"type":"antigravity","access_token":"` + tt.existingToken + `","disabled":false}`)
|
|
if errWrite := os.WriteFile(path, existing, 0o600); errWrite != nil {
|
|
t.Fatalf("write existing auth file: %v", errWrite)
|
|
}
|
|
|
|
store := NewFileTokenStore()
|
|
store.SetBaseDir(baseDir)
|
|
auth := &cliproxyauth.Auth{
|
|
ID: fileName,
|
|
FileName: fileName,
|
|
Metadata: map[string]any{
|
|
"type": "antigravity",
|
|
"access_token": tt.savedToken,
|
|
},
|
|
}
|
|
|
|
savedPath, errSave := store.Save(context.Background(), auth)
|
|
if errSave != nil {
|
|
t.Fatalf("Save() error = %v", errSave)
|
|
}
|
|
if savedPath != path {
|
|
t.Fatalf("Save() path = %q, want %q", savedPath, path)
|
|
}
|
|
if got := auth.Attributes[cliproxyauth.AttributePath]; got != path {
|
|
t.Errorf("path attribute = %q, want %q", got, path)
|
|
}
|
|
if got := auth.Attributes[cliproxyauth.AttributeSource]; got != path {
|
|
t.Errorf("source attribute = %q, want %q", got, path)
|
|
}
|
|
if got := auth.Attributes[cliproxyauth.AttributeSourceBackend]; got != cliproxyauth.AuthSourceFile {
|
|
t.Errorf("source backend attribute = %q, want %q", got, cliproxyauth.AuthSourceFile)
|
|
}
|
|
persisted, errRead := os.ReadFile(path)
|
|
if errRead != nil {
|
|
t.Fatalf("read saved auth file: %v", errRead)
|
|
}
|
|
expected := []byte(`{"type":"antigravity","access_token":"` + tt.savedToken + `","disabled":false}`)
|
|
if !jsonEqual(persisted, expected) {
|
|
t.Errorf("saved auth file = %s, want JSON equal to %s", persisted, expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFileTokenStoreNormalizesLegacyCredentialMetadata(t *testing.T) {
|
|
t.Run("save", func(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
store := NewFileTokenStore()
|
|
store.SetBaseDir(baseDir)
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "legacy-save.json",
|
|
FileName: "legacy-save.json",
|
|
Metadata: map[string]any{
|
|
"type": "codex",
|
|
"request-retry": 2,
|
|
"request_retry": 0,
|
|
"disable-cooling": true,
|
|
},
|
|
}
|
|
|
|
path, errSave := store.Save(context.Background(), auth)
|
|
if errSave != nil {
|
|
t.Fatalf("Save() error = %v", errSave)
|
|
}
|
|
persisted, errRead := os.ReadFile(path)
|
|
if errRead != nil {
|
|
t.Fatalf("read saved auth file: %v", errRead)
|
|
}
|
|
want := []byte(`{"type":"codex","request_retry":0,"disable_cooling":true,"disabled":false}`)
|
|
if !jsonEqual(persisted, want) {
|
|
t.Fatalf("saved auth file = %s, want JSON equal to %s", persisted, want)
|
|
}
|
|
})
|
|
|
|
t.Run("list", func(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
path := filepath.Join(baseDir, "legacy-list.json")
|
|
if errWrite := os.WriteFile(path, []byte(`{"type":"codex","request-retry":2,"disable-cooling":true}`), 0o600); errWrite != nil {
|
|
t.Fatalf("write legacy auth file: %v", errWrite)
|
|
}
|
|
store := NewFileTokenStore()
|
|
store.SetBaseDir(baseDir)
|
|
|
|
auths, errList := store.List(context.Background())
|
|
if errList != nil {
|
|
t.Fatalf("List() error = %v", errList)
|
|
}
|
|
if len(auths) != 1 {
|
|
t.Fatalf("List() len = %d, want 1", len(auths))
|
|
}
|
|
if got := auths[0].Metadata["request_retry"]; got != float64(2) {
|
|
t.Fatalf("listed request_retry = %#v, want 2", got)
|
|
}
|
|
if got := auths[0].Metadata["disable_cooling"]; got != true {
|
|
t.Fatalf("listed disable_cooling = %#v, want true", got)
|
|
}
|
|
for _, legacy := range []string{"request-retry", "disable-cooling"} {
|
|
if _, exists := auths[0].Metadata[legacy]; exists {
|
|
t.Fatalf("listed metadata retained %q: %#v", legacy, auths[0].Metadata)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFileTokenStoreSaveRejectsInvalidWeight(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
store := NewFileTokenStore()
|
|
store.SetBaseDir(baseDir)
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "invalid.json",
|
|
FileName: "invalid.json",
|
|
Metadata: map[string]any{
|
|
"type": "test",
|
|
cliproxyauth.AttributeWeight: 1.5,
|
|
},
|
|
}
|
|
|
|
if _, errSave := store.Save(context.Background(), auth); errSave == nil {
|
|
t.Fatal("Save() accepted an invalid weight")
|
|
}
|
|
if _, errStat := os.Stat(filepath.Join(baseDir, auth.FileName)); !os.IsNotExist(errStat) {
|
|
t.Fatalf("invalid auth file was persisted: %v", errStat)
|
|
}
|
|
}
|
|
|
|
func TestFileTokenStoreListSkipsInvalidPluginSourceWeight(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
path := filepath.Join(baseDir, "plugin.json")
|
|
if errWrite := os.WriteFile(path, []byte(`{"type":"plugin","weight":"invalid"}`), 0o600); errWrite != nil {
|
|
t.Fatalf("write auth file: %v", errWrite)
|
|
}
|
|
|
|
parserCalled := false
|
|
RegisterPluginAuthParser(fileStoreMultiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) {
|
|
parserCalled = true
|
|
return []*cliproxyauth.Auth{{ID: "plugin.json", Provider: "plugin"}}, true, nil
|
|
}))
|
|
t.Cleanup(func() {
|
|
RegisterPluginAuthParser(nil)
|
|
})
|
|
|
|
store := NewFileTokenStore()
|
|
store.SetBaseDir(baseDir)
|
|
auths, errList := store.List(context.Background())
|
|
if errList != nil {
|
|
t.Fatalf("List() error = %v", errList)
|
|
}
|
|
if parserCalled {
|
|
t.Fatal("plugin parser was called for an invalid persisted source")
|
|
}
|
|
if len(auths) != 0 {
|
|
t.Fatalf("List() returned invalid plugin auths: %#v", auths)
|
|
}
|
|
}
|
|
|
|
func TestFileTokenStoreListExpandsPluginMultiAuths(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
path := filepath.Join(baseDir, "geminicli.json")
|
|
if errWrite := os.WriteFile(path, []byte(`{"type":"gemini-cli","weight":3,"headers":{"X-Test":"value"}}`), 0o600); errWrite != nil {
|
|
t.Fatalf("write auth file: %v", errWrite)
|
|
}
|
|
|
|
RegisterPluginAuthParser(fileStoreMultiAuthParserFunc(func(ctx context.Context, req pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) {
|
|
if req.Provider != "gemini-cli" || req.Path != path || req.FileName != "geminicli.json" {
|
|
t.Fatalf("ParseAuths request = %#v, want file context", req)
|
|
}
|
|
return []*cliproxyauth.Auth{
|
|
{
|
|
ID: "geminicli.json",
|
|
Provider: "gemini-cli",
|
|
Metadata: map[string]any{
|
|
"type": "gemini-cli",
|
|
"headers": map[string]any{
|
|
"X-Test": "value",
|
|
},
|
|
},
|
|
},
|
|
nil,
|
|
{
|
|
ID: "geminicli-project-a.json",
|
|
Provider: "gemini-cli",
|
|
Metadata: map[string]any{
|
|
"type": "gemini-cli",
|
|
"project_id": "project-a",
|
|
"headers": map[string]any{
|
|
"X-Test": "value",
|
|
},
|
|
},
|
|
},
|
|
}, true, nil
|
|
}))
|
|
t.Cleanup(func() {
|
|
RegisterPluginAuthParser(nil)
|
|
})
|
|
|
|
store := NewFileTokenStore()
|
|
store.SetBaseDir(baseDir)
|
|
auths, errList := store.List(context.Background())
|
|
if errList != nil {
|
|
t.Fatalf("List() error = %v", errList)
|
|
}
|
|
if len(auths) != 2 {
|
|
t.Fatalf("List() len = %d, want two plugin auths", len(auths))
|
|
}
|
|
if firstIndex, secondIndex := auths[0].EnsureIndex(), auths[1].EnsureIndex(); firstIndex == "" || firstIndex == secondIndex {
|
|
t.Fatalf("auth indexes = %q/%q, want distinct non-empty indexes", firstIndex, secondIndex)
|
|
}
|
|
for _, auth := range auths {
|
|
if !cliproxyauth.IsPluginVirtualAuth(auth) {
|
|
t.Fatalf("auth attributes = %#v, want plugin virtual marker", auth.Attributes)
|
|
}
|
|
if auth.Attributes[cliproxyauth.AttributeVirtualSource] != path {
|
|
t.Fatalf("virtual_source = %q, want %q", auth.Attributes[cliproxyauth.AttributeVirtualSource], path)
|
|
}
|
|
if auth.Attributes["path"] != path || auth.Attributes["source"] != path {
|
|
t.Fatalf("auth attributes = %#v, want source path", auth.Attributes)
|
|
}
|
|
if gotHeader := auth.Attributes["header:X-Test"]; gotHeader != "value" {
|
|
t.Fatalf("header:X-Test = %q, want value", gotHeader)
|
|
}
|
|
if gotWeight := auth.Attributes[cliproxyauth.AttributeWeight]; gotWeight != "3" {
|
|
t.Fatalf("weight = %q, want 3", gotWeight)
|
|
}
|
|
}
|
|
if gotProject := auths[1].Metadata["project_id"]; gotProject != "project-a" {
|
|
t.Fatalf("project_id = %#v, want project-a", gotProject)
|
|
}
|
|
}
|
|
|
|
func TestFileTokenStoreListAppliesSourceDisabledToPluginMultiAuths(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
path := filepath.Join(baseDir, "geminicli.json")
|
|
if errWrite := os.WriteFile(path, []byte(`{"type":"gemini-cli","disabled":true}`), 0o600); errWrite != nil {
|
|
t.Fatalf("write auth file: %v", errWrite)
|
|
}
|
|
|
|
RegisterPluginAuthParser(fileStoreMultiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) {
|
|
return []*cliproxyauth.Auth{
|
|
{ID: "geminicli.json", Provider: "gemini-cli", Metadata: map[string]any{"type": "gemini-cli"}},
|
|
{ID: "geminicli-project-a.json", Provider: "gemini-cli", Metadata: map[string]any{"type": "gemini-cli", "project_id": "project-a"}},
|
|
}, true, nil
|
|
}))
|
|
t.Cleanup(func() {
|
|
RegisterPluginAuthParser(nil)
|
|
})
|
|
|
|
store := NewFileTokenStore()
|
|
store.SetBaseDir(baseDir)
|
|
auths, errList := store.List(context.Background())
|
|
if errList != nil {
|
|
t.Fatalf("List() error = %v", errList)
|
|
}
|
|
if len(auths) != 2 {
|
|
t.Fatalf("List() len = %d, want two plugin auths", len(auths))
|
|
}
|
|
for _, auth := range auths {
|
|
if !auth.Disabled || auth.Status != cliproxyauth.StatusDisabled {
|
|
t.Fatalf("auth %s disabled/status = %v/%s, want disabled", auth.ID, auth.Disabled, auth.Status)
|
|
}
|
|
if got, _ := auth.Metadata["disabled"].(bool); !got {
|
|
t.Fatalf("auth %s metadata disabled = %#v, want true", auth.ID, auth.Metadata["disabled"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFileTokenStoreListPluginHandledEmptySuppressesBuiltin(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
path := filepath.Join(baseDir, "codex.json")
|
|
if errWrite := os.WriteFile(path, []byte(`{"type":"codex","access_token":"token"}`), 0o600); errWrite != nil {
|
|
t.Fatalf("write auth file: %v", errWrite)
|
|
}
|
|
|
|
RegisterPluginAuthParser(fileStoreMultiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) {
|
|
return nil, true, nil
|
|
}))
|
|
t.Cleanup(func() {
|
|
RegisterPluginAuthParser(nil)
|
|
})
|
|
|
|
store := NewFileTokenStore()
|
|
store.SetBaseDir(baseDir)
|
|
auths, errList := store.List(context.Background())
|
|
if errList != nil {
|
|
t.Fatalf("List() error = %v", errList)
|
|
}
|
|
if len(auths) != 0 {
|
|
t.Fatalf("List() len = %d, want plugin-handled empty result", len(auths))
|
|
}
|
|
}
|
|
|
|
type fileStoreMultiAuthParserFunc func(context.Context, pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error)
|
|
|
|
func (f fileStoreMultiAuthParserFunc) ParseAuth(context.Context, pluginapi.AuthParseRequest) (*cliproxyauth.Auth, bool, error) {
|
|
return nil, false, nil
|
|
}
|
|
|
|
func (f fileStoreMultiAuthParserFunc) ParseAuths(ctx context.Context, req pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) {
|
|
return f(ctx, req)
|
|
}
|