mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-23 08:04:25 +08:00
- Persist the real SIWC token expiration time from expires_in and evaluate local SIWC status using real expiration (or JWT exp) and upstream 401 rejection state without triggering network requests. - Track SIWC token rejections independently from outer Codex OAuth refreshes, execution retries, and use_siwc toggles until the SIWC token is replaced. - Expose the read-only v8 management SIWC status endpoint and summary field for Codex credentials. Closes: #6819
219 lines
8.2 KiB
Go
219 lines
8.2 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/router-for-me/CLIProxyAPI/v8/internal/api/handlers/management"
|
|
"github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex"
|
|
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
|
|
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func TestSIWCManagementRouteAccessControl(t *testing.T) {
|
|
hash, errHash := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
|
if errHash != nil {
|
|
t.Fatal(errHash)
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
enabled, home, authorized bool
|
|
want int
|
|
}{
|
|
{"authorized invalid target", true, false, true, http.StatusBadRequest},
|
|
{"missing management key", true, false, false, http.StatusUnauthorized},
|
|
{"management disabled", false, false, true, http.StatusNotFound},
|
|
{"home disabled local API", true, true, true, http.StatusNotFound},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := &config.Config{AuthDir: t.TempDir()}
|
|
cfg.Home.Enabled = tc.home
|
|
cfg.RemoteManagement.SecretKey = string(hash)
|
|
h := management.NewHandlerWithoutConfigFilePath(cfg, nil)
|
|
h.SetLocalPassword("test-password")
|
|
s := &Server{cfg: cfg, engine: gin.New(), mgmt: h}
|
|
s.managementRoutesEnabled.Store(tc.enabled)
|
|
s.registerManagementRoutes()
|
|
req := httptest.NewRequest(http.MethodPost, "/v8/management/oauth/siwc/bind", strings.NewReader(`{}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
if tc.authorized {
|
|
req.Header.Set("Authorization", "Bearer test-password")
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.engine.ServeHTTP(w, req)
|
|
if w.Code != tc.want {
|
|
t.Fatalf("status=%d want=%d: %s", w.Code, tc.want, w.Body.String())
|
|
}
|
|
for _, route := range s.engine.Routes() {
|
|
if strings.HasPrefix(route.Path, "/v0/") && strings.Contains(route.Path, "siwc") {
|
|
t.Fatal("SIWC route registered under v0")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCodexCallbackRejectsSIWCState(t *testing.T) {
|
|
hash, errHash := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
|
if errHash != nil {
|
|
t.Fatal(errHash)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.RemoteManagement.SecretKey = string(hash)
|
|
s := newTestServerWithConfig(t, cfg)
|
|
metadata := map[string]any{"type": "codex", "access_token": "outer-token", "refresh_token": "outer-refresh"}
|
|
data, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(s.cfg.AuthDir, "codex.json")
|
|
if errWrite := os.WriteFile(path, data, 0o600); errWrite != nil {
|
|
t.Fatal(errWrite)
|
|
}
|
|
manager := coreauth.NewManager(nil, nil, nil)
|
|
manager.SetConfig(s.cfg)
|
|
if _, errRegister := manager.Register(context.Background(), &coreauth.Auth{
|
|
ID: "codex.json", FileName: "codex.json", Provider: "codex", Status: coreauth.StatusActive,
|
|
Metadata: metadata, Attributes: map[string]string{"path": path},
|
|
}); errRegister != nil {
|
|
t.Fatal(errRegister)
|
|
}
|
|
s.mgmt.SetAuthManager(manager)
|
|
s.mgmt.SetLocalPassword("test-password")
|
|
s.managementRoutesEnabled.Store(true)
|
|
s.registerManagementRoutes()
|
|
req := httptest.NewRequest(http.MethodPost, "/v8/management/oauth/siwc/bind", strings.NewReader(`{"name":"codex.json"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-password")
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
started := httptest.NewRecorder()
|
|
s.engine.ServeHTTP(started, req)
|
|
if started.Code != http.StatusOK {
|
|
t.Fatalf("start status=%d: %s", started.Code, started.Body.String())
|
|
}
|
|
var flow struct {
|
|
State string `json:"state"`
|
|
Flow string `json:"flow"`
|
|
}
|
|
if errDecode := json.Unmarshal(started.Body.Bytes(), &flow); errDecode != nil {
|
|
t.Fatal(errDecode)
|
|
}
|
|
if flow.State == "" || flow.Flow != "siwc_bind" {
|
|
t.Fatalf("unexpected bind response: %s", started.Body.String())
|
|
}
|
|
t.Cleanup(func() { management.CancelOAuthSession(flow.State) })
|
|
callback := httptest.NewRecorder()
|
|
query := url.Values{"state": {flow.State}, "code": {"issued-code"}, "client_id": {"oaiapp_issued"}}
|
|
s.engine.ServeHTTP(callback, httptest.NewRequest(http.MethodGet, "/codex/callback?"+query.Encode(), nil))
|
|
if callback.Code != http.StatusConflict {
|
|
t.Fatalf("callback status=%d want=409: %s", callback.Code, callback.Body.String())
|
|
}
|
|
if !strings.Contains(callback.Body.String(), "/v8/management/oauth/callback") {
|
|
t.Fatalf("missing v8 callback guidance: %s", callback.Body.String())
|
|
}
|
|
entries, errRead := os.ReadDir(s.cfg.AuthDir)
|
|
if errRead != nil {
|
|
t.Fatal(errRead)
|
|
}
|
|
for _, entry := range entries {
|
|
if strings.HasPrefix(entry.Name(), ".oauth") {
|
|
t.Fatalf("legacy callback created OAuth file %q", entry.Name())
|
|
}
|
|
}
|
|
if !management.IsOAuthSessionPending(flow.State, "codex") {
|
|
t.Fatal("rejected callback consumed SIWC session")
|
|
}
|
|
}
|
|
|
|
func TestSIWCStatusEndpoint(t *testing.T) {
|
|
hash, errHash := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
|
if errHash != nil {
|
|
t.Fatal(errHash)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.RemoteManagement.SecretKey = string(hash)
|
|
s := newTestServerWithConfig(t, cfg)
|
|
metadata := map[string]any{"type": "codex", "access_token": "outer-token", "refresh_token": "outer-refresh"}
|
|
path := filepath.Join(s.cfg.AuthDir, "codex.json")
|
|
data, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if errWrite := os.WriteFile(path, data, 0o600); err != nil || errWrite != nil {
|
|
t.Fatal(errWrite)
|
|
}
|
|
manager := coreauth.NewManager(nil, nil, nil)
|
|
manager.SetConfig(s.cfg)
|
|
if _, errRegister := manager.Register(context.Background(), &coreauth.Auth{
|
|
ID: "codex.json", FileName: "codex.json", Provider: "codex", Status: coreauth.StatusActive,
|
|
Metadata: metadata, Attributes: map[string]string{"path": path},
|
|
}); errRegister != nil {
|
|
t.Fatal(errRegister)
|
|
}
|
|
s.mgmt.SetAuthManager(manager)
|
|
s.mgmt.SetLocalPassword("test-password")
|
|
s.managementRoutesEnabled.Store(true)
|
|
s.registerManagementRoutes()
|
|
|
|
checkStatus := func(url string, wantCode int, wantBody string) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
req.Header.Set("Authorization", "Bearer test-password")
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
w := httptest.NewRecorder()
|
|
s.engine.ServeHTTP(w, req)
|
|
if w.Code != wantCode {
|
|
t.Fatalf("%s: status=%d want=%d: %s", url, w.Code, wantCode, w.Body.String())
|
|
}
|
|
if wantBody != "" && strings.TrimSpace(w.Body.String()) != wantBody {
|
|
t.Fatalf("%s: body=%s want %s", url, w.Body.String(), wantBody)
|
|
}
|
|
}
|
|
|
|
// 1. use_siwc not enabled -> off
|
|
checkStatus("/v8/management/oauth/siwc/status?name=codex.json", http.StatusOK, `{"status":"off"}`)
|
|
checkStatus("/v8/management/credentials/siwc/status?name=codex.json", http.StatusOK, `{"status":"off"}`)
|
|
|
|
// 2. Bind SIWC with valid future expires_at -> valid
|
|
registered, _ := manager.GetByID("codex.json")
|
|
_, errBind := manager.BindCodexSIWC(context.Background(), "codex.json", registered.RegistrationEpoch, "", &codex.CodexSIWCTokenData{
|
|
AccessToken: "siwc-valid-1",
|
|
RefreshToken: "siwc-refresh-1",
|
|
ClientID: "oaiapp_1",
|
|
Expire: "2035-01-01T00:00:00Z",
|
|
ExpiresAt: "2035-01-01T00:03:00Z",
|
|
})
|
|
if errBind != nil {
|
|
t.Fatalf("BindCodexSIWC: %v", errBind)
|
|
}
|
|
bound, _ := manager.GetByID("codex.json")
|
|
bound.EnsureIndex()
|
|
checkStatus("/v8/management/oauth/siwc/status?name=codex.json", http.StatusOK, `{"status":"valid"}`)
|
|
checkStatus("/v8/management/oauth/siwc/status?auth_index="+bound.Index, http.StatusOK, `{"status":"valid"}`)
|
|
|
|
// 3. SIWC request receives 401 -> invalid
|
|
manager.MarkResult(context.Background(), coreauth.Result{
|
|
AuthID: "codex.json",
|
|
CredentialVersion: bound.CredentialVersion,
|
|
Provider: "codex",
|
|
Model: "gpt-5.4",
|
|
Success: false,
|
|
Error: &coreauth.Error{HTTPStatus: http.StatusUnauthorized, Code: "unauthorized", Message: "401 unauthorized"},
|
|
})
|
|
checkStatus("/v8/management/oauth/siwc/status?name=codex.json", http.StatusOK, `{"status":"invalid"}`)
|
|
|
|
// 4. Missing parameter or unknown target
|
|
checkStatus("/v8/management/oauth/siwc/status", http.StatusBadRequest, "")
|
|
checkStatus("/v8/management/oauth/siwc/status?name=missing.json", http.StatusNotFound, "")
|
|
}
|