mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-06 16:15:50 +08:00
367 lines
16 KiB
Go
367 lines
16 KiB
Go
package helps
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
)
|
|
|
|
func TestStatusFromHomeErrorCodeMapsAuthenticationErrorToUnauthorized(t *testing.T) {
|
|
if got := statusFromHomeErrorCode("authentication_error"); got != http.StatusUnauthorized {
|
|
t.Fatalf("statusFromHomeErrorCode(authentication_error) = %d, want %d", got, http.StatusUnauthorized)
|
|
}
|
|
if got := statusFromHomeErrorCode("unauthorized"); got != http.StatusUnauthorized {
|
|
t.Fatalf("statusFromHomeErrorCode(unauthorized) = %d, want %d", got, http.StatusUnauthorized)
|
|
}
|
|
for _, code := range []string{"auth_not_found", "auth_unavailable", "refresh_temporarily_unavailable", "refresh_unsupported"} {
|
|
if got := statusFromHomeErrorCode(code); got != http.StatusServiceUnavailable {
|
|
t.Fatalf("statusFromHomeErrorCode(%s) = %d, want %d", code, got, http.StatusServiceUnavailable)
|
|
}
|
|
}
|
|
}
|
|
|
|
type fakeHomeRefreshClient struct {
|
|
calls atomic.Int32
|
|
authIndex string
|
|
accessTokenHash string
|
|
raw []byte
|
|
err error
|
|
}
|
|
|
|
func (c *fakeHomeRefreshClient) HeartbeatOK() bool {
|
|
return true
|
|
}
|
|
|
|
func (c *fakeHomeRefreshClient) GetRefreshAuth(_ context.Context, authIndex string, accessTokenHash string) ([]byte, error) {
|
|
c.calls.Add(1)
|
|
c.authIndex = authIndex
|
|
c.accessTokenHash = accessTokenHash
|
|
return c.raw, c.err
|
|
}
|
|
|
|
func TestRefreshAuthViaHomePreservesContextErrors(t *testing.T) {
|
|
client := &fakeHomeRefreshClient{err: context.DeadlineExceeded}
|
|
oldCurrentHomeRefreshClient := currentHomeRefreshClient
|
|
currentHomeRefreshClient = func() homeRefreshClient { return client }
|
|
t.Cleanup(func() { currentHomeRefreshClient = oldCurrentHomeRefreshClient })
|
|
|
|
cfg := &config.Config{Home: config.HomeConfig{Enabled: true}}
|
|
auth := &cliproxyauth.Auth{ID: "home-auth", Index: "home-auth", Provider: "codex"}
|
|
_, handled, errRefresh := RefreshAuthViaHome(context.Background(), cfg, auth)
|
|
if !handled || !errors.Is(errRefresh, context.DeadlineExceeded) {
|
|
t.Fatalf("RefreshAuthViaHome() = handled %v err %v, want true/context.DeadlineExceeded", handled, errRefresh)
|
|
}
|
|
}
|
|
|
|
func TestHomeStatusErrLogDiagnosticSanitizesUpstreamFallback(t *testing.T) {
|
|
errRefresh := homeStatusErr{
|
|
code: http.StatusBadGateway,
|
|
msg: "upstream EOF access_token=provider-secret",
|
|
upstream: true,
|
|
}
|
|
diagnostic := errRefresh.LogDiagnostic()
|
|
if diagnostic != "Home refresh upstream response: status=502" || strings.Contains(diagnostic, "provider-secret") {
|
|
t.Fatalf("LogDiagnostic() = %q, want safe upstream fallback", diagnostic)
|
|
}
|
|
if errRefresh.Error() != "upstream EOF access_token=provider-secret" {
|
|
t.Fatalf("Error() = %q, want exact upstream response", errRefresh.Error())
|
|
}
|
|
}
|
|
|
|
func TestRefreshAuthViaHomeMapsTransportFailureToGeneric503(t *testing.T) {
|
|
client := &fakeHomeRefreshClient{err: errors.New("dial failed with provider-secret")}
|
|
oldCurrentHomeRefreshClient := currentHomeRefreshClient
|
|
currentHomeRefreshClient = func() homeRefreshClient { return client }
|
|
t.Cleanup(func() { currentHomeRefreshClient = oldCurrentHomeRefreshClient })
|
|
|
|
cfg := &config.Config{Home: config.HomeConfig{Enabled: true}}
|
|
auth := &cliproxyauth.Auth{ID: "home-auth", Index: "home-auth", Provider: "codex"}
|
|
_, handled, errRefresh := RefreshAuthViaHome(context.Background(), cfg, auth)
|
|
statusErr, okStatus := errRefresh.(interface{ StatusCode() int })
|
|
if !handled || !okStatus || statusErr.StatusCode() != http.StatusServiceUnavailable {
|
|
t.Fatalf("RefreshAuthViaHome() = handled %v err %v, want generic 503", handled, errRefresh)
|
|
}
|
|
if strings.Contains(errRefresh.Error(), "provider-secret") {
|
|
t.Fatalf("refresh error included transport detail: %v", errRefresh)
|
|
}
|
|
direct, okDirect := errRefresh.(interface {
|
|
DirectResponse() bool
|
|
ResponseBody() []byte
|
|
})
|
|
if !okDirect || direct.DirectResponse() {
|
|
t.Fatalf("transport error direct response = %v/%v, want false", okDirect, direct)
|
|
}
|
|
diagnosticErr, okDiagnostic := errRefresh.(interface{ LogDiagnostic() string })
|
|
if !okDiagnostic || !strings.Contains(diagnosticErr.LogDiagnostic(), "dial_failed") {
|
|
t.Fatalf("transport log diagnostic = %T/%v, want allowlisted transport cause", errRefresh, errRefresh)
|
|
}
|
|
if strings.Contains(diagnosticErr.LogDiagnostic(), "provider-secret") {
|
|
t.Fatalf("transport log diagnostic leaked provider detail: %q", diagnosticErr.LogDiagnostic())
|
|
}
|
|
}
|
|
|
|
func TestRefreshAuthViaHomeUsesGenericMessageForLegacyErrorEnvelope(t *testing.T) {
|
|
client := &fakeHomeRefreshClient{raw: []byte(`{"error":{"type":"error","message":"provider response: refresh_token=provider-secret"}}`)}
|
|
oldCurrentHomeRefreshClient := currentHomeRefreshClient
|
|
currentHomeRefreshClient = func() homeRefreshClient { return client }
|
|
t.Cleanup(func() { currentHomeRefreshClient = oldCurrentHomeRefreshClient })
|
|
|
|
cfg := &config.Config{Home: config.HomeConfig{Enabled: true}}
|
|
auth := &cliproxyauth.Auth{ID: "home-auth", Index: "home-auth", Provider: "codex"}
|
|
_, handled, errRefresh := RefreshAuthViaHome(context.Background(), cfg, auth)
|
|
statusErr, okStatus := errRefresh.(interface{ StatusCode() int })
|
|
if !handled || !okStatus || statusErr.StatusCode() != http.StatusServiceUnavailable {
|
|
t.Fatalf("RefreshAuthViaHome() = handled %v err %v, want generic 503", handled, errRefresh)
|
|
}
|
|
if strings.Contains(errRefresh.Error(), "provider-secret") {
|
|
t.Fatalf("refresh error included legacy Home detail: %v", errRefresh)
|
|
}
|
|
if diagnosticErr, ok := errRefresh.(interface{ LogDiagnostic() string }); !ok || diagnosticErr.LogDiagnostic() != "Home refresh failed: type=error" {
|
|
t.Fatalf("legacy Home error type log diagnostic = %T/%v", errRefresh, errRefresh)
|
|
}
|
|
}
|
|
|
|
func TestRefreshAuthViaHomeUsesDedicatedDiagnosticOnlyForLogs(t *testing.T) {
|
|
const diagnostic = "antigravity refresh failed: stage=transport err=EOF"
|
|
client := &fakeHomeRefreshClient{raw: []byte(`{"error":{"type":"refresh_temporarily_unavailable","message":"untrusted provider-secret","diagnostic":"` + diagnostic + `"}}`)}
|
|
oldCurrentHomeRefreshClient := currentHomeRefreshClient
|
|
currentHomeRefreshClient = func() homeRefreshClient { return client }
|
|
t.Cleanup(func() { currentHomeRefreshClient = oldCurrentHomeRefreshClient })
|
|
|
|
cfg := &config.Config{Home: config.HomeConfig{Enabled: true}}
|
|
auth := &cliproxyauth.Auth{ID: "home-auth", Index: "home-auth", Provider: "antigravity"}
|
|
_, handled, errRefresh := RefreshAuthViaHome(context.Background(), cfg, auth)
|
|
if !handled || errRefresh == nil || errRefresh.Error() != "credential refresh temporarily unavailable" {
|
|
t.Fatalf("RefreshAuthViaHome() = handled %v err %v, want generic client error", handled, errRefresh)
|
|
}
|
|
diagnosticErr, ok := errRefresh.(interface{ LogDiagnostic() string })
|
|
if !ok || diagnosticErr.LogDiagnostic() != diagnostic {
|
|
t.Fatalf("log diagnostic = %T/%v, want %q", errRefresh, errRefresh, diagnostic)
|
|
}
|
|
if strings.Contains(errRefresh.Error(), diagnostic) || strings.Contains(errRefresh.Error(), "provider-secret") {
|
|
t.Fatalf("client error exposed internal detail: %v", errRefresh)
|
|
}
|
|
}
|
|
|
|
func TestRefreshAuthViaHomePreservesUpstreamStatusAndBodyExactly(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body []byte
|
|
}{
|
|
{name: "json", status: http.StatusBadRequest, body: []byte(`{"error":"invalid_request"}`)},
|
|
{name: "access token expired", status: http.StatusUnauthorized, body: []byte(`{"error":{"message":"access token expired"}}`)},
|
|
{name: "text", status: http.StatusBadGateway, body: []byte("provider unavailable")},
|
|
{name: "multiline", status: http.StatusTooManyRequests, body: []byte("first line\r\nsecond line\n")},
|
|
{name: "empty", status: http.StatusUnauthorized, body: []byte{}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
raw, errMarshal := json.Marshal(homeErrorEnvelope{Error: &homeErrorDetail{
|
|
Type: "refresh_temporarily_unavailable",
|
|
Message: "credential refresh temporarily unavailable",
|
|
Diagnostic: "antigravity refresh failed: stage=upstream_response status=400",
|
|
Upstream: &homeUpstreamResponse{
|
|
Status: tt.status,
|
|
Body: tt.body,
|
|
},
|
|
}})
|
|
if errMarshal != nil {
|
|
t.Fatalf("marshal Home error envelope: %v", errMarshal)
|
|
}
|
|
client := &fakeHomeRefreshClient{raw: raw}
|
|
oldCurrentHomeRefreshClient := currentHomeRefreshClient
|
|
currentHomeRefreshClient = func() homeRefreshClient { return client }
|
|
t.Cleanup(func() { currentHomeRefreshClient = oldCurrentHomeRefreshClient })
|
|
|
|
cfg := &config.Config{Home: config.HomeConfig{Enabled: true}}
|
|
auth := &cliproxyauth.Auth{ID: "home-auth", Index: "home-auth", Provider: "codex"}
|
|
_, handled, errRefresh := RefreshAuthViaHome(context.Background(), cfg, auth)
|
|
statusErr, okStatus := errRefresh.(interface{ StatusCode() int })
|
|
if !handled || !okStatus || statusErr.StatusCode() != tt.status {
|
|
t.Fatalf("RefreshAuthViaHome() = handled %v err %v status %v, want true/%d", handled, errRefresh, okStatus, tt.status)
|
|
}
|
|
if got := []byte(errRefresh.Error()); !bytes.Equal(got, tt.body) {
|
|
t.Fatalf("refresh body = %q, want exact body %q", got, tt.body)
|
|
}
|
|
direct, okDirect := errRefresh.(interface {
|
|
DirectResponse() bool
|
|
ResponseBody() []byte
|
|
})
|
|
if !okDirect || !direct.DirectResponse() {
|
|
t.Fatalf("upstream error direct response = %v/%v, want true", okDirect, direct)
|
|
}
|
|
if got := direct.ResponseBody(); !bytes.Equal(got, tt.body) {
|
|
t.Fatalf("direct response body = %q, want exact body %q", got, tt.body)
|
|
}
|
|
diagnosticErr, okDiagnostic := errRefresh.(interface{ LogDiagnostic() string })
|
|
if !okDiagnostic || !strings.Contains(diagnosticErr.LogDiagnostic(), "stage=upstream_response") {
|
|
t.Fatalf("upstream log diagnostic = %T/%v", errRefresh, errRefresh)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRefreshAuthViaHomeUsesGenericMessageForLegacyProviderDiagnostics(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
provider string
|
|
raw []byte
|
|
wantStatus int
|
|
wantError string
|
|
wantLog string
|
|
}{
|
|
{
|
|
name: "transient transport diagnostic",
|
|
provider: "antigravity",
|
|
raw: []byte(`{"error":{"type":"refresh_temporarily_unavailable","message":"antigravity refresh: Post https://oauth.example/token?access_token=provider-secret: connection refused"}}`),
|
|
wantStatus: http.StatusServiceUnavailable,
|
|
wantError: "credential refresh temporarily unavailable",
|
|
wantLog: "Home refresh failed: type=refresh_temporarily_unavailable",
|
|
},
|
|
{
|
|
name: "terminal legacy diagnostic",
|
|
provider: "codex",
|
|
raw: []byte(`{"error":{"type":"authentication_error","message":"codex refresh: invalid_grant refresh_token=provider-secret"}}`),
|
|
wantStatus: http.StatusUnauthorized,
|
|
wantError: "credential unauthorized",
|
|
wantLog: "Home refresh failed: type=authentication_error",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
client := &fakeHomeRefreshClient{raw: tt.raw}
|
|
oldCurrentHomeRefreshClient := currentHomeRefreshClient
|
|
currentHomeRefreshClient = func() homeRefreshClient { return client }
|
|
t.Cleanup(func() { currentHomeRefreshClient = oldCurrentHomeRefreshClient })
|
|
|
|
cfg := &config.Config{Home: config.HomeConfig{Enabled: true}}
|
|
auth := &cliproxyauth.Auth{ID: "home-auth", Index: "home-auth", Provider: tt.provider}
|
|
_, handled, errRefresh := RefreshAuthViaHome(context.Background(), cfg, auth)
|
|
statusErr, okStatus := errRefresh.(interface{ StatusCode() int })
|
|
if !handled || !okStatus || statusErr.StatusCode() != tt.wantStatus {
|
|
t.Fatalf("RefreshAuthViaHome() = handled %v err %v, want status %d", handled, errRefresh, tt.wantStatus)
|
|
}
|
|
if got := errRefresh.Error(); got != tt.wantError {
|
|
t.Fatalf("client refresh error = %q, want %q", got, tt.wantError)
|
|
}
|
|
if strings.Contains(errRefresh.Error(), "provider-secret") {
|
|
t.Fatalf("legacy refresh error leaked provider detail: %v", errRefresh)
|
|
}
|
|
diagnosticErr, okDiagnostic := errRefresh.(interface{ LogDiagnostic() string })
|
|
if !okDiagnostic {
|
|
t.Fatalf("legacy refresh log diagnostic type = %T, want LogDiagnostic", errRefresh)
|
|
}
|
|
if got := diagnosticErr.LogDiagnostic(); got != tt.wantLog {
|
|
t.Fatalf("legacy refresh log diagnostic = %q, want %q", got, tt.wantLog)
|
|
}
|
|
if strings.Contains(diagnosticErr.LogDiagnostic(), "provider-secret") {
|
|
t.Fatalf("legacy refresh log diagnostic leaked provider detail: %q", diagnosticErr.LogDiagnostic())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRefreshAuthViaHomeRejectsUnmarkedRefreshMessage(t *testing.T) {
|
|
client := &fakeHomeRefreshClient{raw: []byte(`{"error":{"type":"refresh_temporarily_unavailable","message":"database unavailable: provider-secret"}}`)}
|
|
oldCurrentHomeRefreshClient := currentHomeRefreshClient
|
|
currentHomeRefreshClient = func() homeRefreshClient { return client }
|
|
t.Cleanup(func() { currentHomeRefreshClient = oldCurrentHomeRefreshClient })
|
|
|
|
cfg := &config.Config{Home: config.HomeConfig{Enabled: true}}
|
|
auth := &cliproxyauth.Auth{ID: "home-auth", Index: "home-auth", Provider: "antigravity"}
|
|
_, _, errRefresh := RefreshAuthViaHome(context.Background(), cfg, auth)
|
|
if got, want := errRefresh.Error(), "credential refresh temporarily unavailable"; got != want {
|
|
t.Fatalf("refresh error = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestAuthAccessTokenSHA256SupportsKnownMetadataShapes(t *testing.T) {
|
|
want := authAccessTokenSHA256(&cliproxyauth.Auth{Metadata: map[string]any{"access_token": "same-token"}})
|
|
cases := map[string]*cliproxyauth.Auth{
|
|
"camel case": {Metadata: map[string]any{"accessToken": "same-token"}},
|
|
"nested any map": {Metadata: map[string]any{"token": map[string]any{"access_token": "same-token"}}},
|
|
"nested string map": {Metadata: map[string]any{"Token": map[string]string{"accessToken": "same-token"}}},
|
|
}
|
|
for name, auth := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
if got := authAccessTokenSHA256(auth); got == "" || got != want {
|
|
t.Fatalf("token hash = %q, want %q", got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRefreshAuthViaHomeAcceptsAuthEnvelope(t *testing.T) {
|
|
raw, errMarshal := json.Marshal(struct {
|
|
Auth cliproxyauth.Auth `json:"auth"`
|
|
AuthIndex string `json:"auth_index"`
|
|
}{
|
|
Auth: cliproxyauth.Auth{
|
|
ID: "home-auth-1",
|
|
Provider: "antigravity",
|
|
Metadata: map[string]any{
|
|
"access_token": "new-access-token",
|
|
},
|
|
},
|
|
AuthIndex: "home-index-1",
|
|
})
|
|
if errMarshal != nil {
|
|
t.Fatalf("marshal home envelope: %v", errMarshal)
|
|
}
|
|
|
|
client := &fakeHomeRefreshClient{raw: raw}
|
|
oldCurrentHomeRefreshClient := currentHomeRefreshClient
|
|
currentHomeRefreshClient = func() homeRefreshClient {
|
|
return client
|
|
}
|
|
t.Cleanup(func() {
|
|
currentHomeRefreshClient = oldCurrentHomeRefreshClient
|
|
})
|
|
|
|
cfg := &config.Config{Home: config.HomeConfig{Enabled: true}}
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "home-auth-1",
|
|
Provider: "antigravity",
|
|
Index: "home-index-1",
|
|
Metadata: map[string]any{
|
|
"access_token": "old-access-token",
|
|
"refresh_token": "refresh-token",
|
|
},
|
|
}
|
|
|
|
updated, handled, err := RefreshAuthViaHome(context.Background(), cfg, auth)
|
|
if err != nil {
|
|
t.Fatalf("RefreshAuthViaHome error: %v", err)
|
|
}
|
|
if !handled {
|
|
t.Fatal("RefreshAuthViaHome handled = false, want true")
|
|
}
|
|
if got := client.calls.Load(); got != 1 {
|
|
t.Fatalf("home refresh calls = %d, want 1", got)
|
|
}
|
|
if client.authIndex != "home-index-1" {
|
|
t.Fatalf("home refresh auth_index = %q, want home-index-1", client.authIndex)
|
|
}
|
|
if client.accessTokenHash != authAccessTokenSHA256(auth) {
|
|
t.Fatalf("home refresh access token hash = %q, want %q", client.accessTokenHash, authAccessTokenSHA256(auth))
|
|
}
|
|
if updated == nil {
|
|
t.Fatal("updated auth = nil")
|
|
}
|
|
if got := updated.Metadata["access_token"]; got != "new-access-token" {
|
|
t.Fatalf("updated access_token = %q, want new-access-token", got)
|
|
}
|
|
if updated.Index != "home-index-1" {
|
|
t.Fatalf("updated auth_index = %q, want home-index-1", updated.Index)
|
|
}
|
|
}
|