mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-24 00:24:23 +08:00
210 lines
8.7 KiB
Go
210 lines
8.7 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/router-for-me/CLIProxyAPI/v8/internal/api/handlers/management"
|
|
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
|
|
)
|
|
|
|
func TestManagementV8RoutesShareAccessControl(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
for _, tc := range []struct {
|
|
name string
|
|
enabled, home, authorized bool
|
|
want int
|
|
}{
|
|
{"authorized", true, false, true, 200},
|
|
{"missing key", true, false, false, 401},
|
|
{"disabled", false, false, true, 404},
|
|
{"home", true, true, true, 404},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
if err := os.WriteFile(path, []byte("port: 8317\nremote-management: {secret-key: test-password}\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := config.LoadConfig(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.Home.Enabled = tc.home
|
|
cfg.Plugins.Dir = filepath.Dir(path)
|
|
h := management.NewHandler(cfg, path, nil)
|
|
h.SetLocalPassword("test-password")
|
|
s := &Server{cfg: cfg, engine: gin.New(), mgmt: h}
|
|
s.managementRoutesEnabled.Store(tc.enabled)
|
|
s.registerManagementRoutes()
|
|
for _, route := range []string{"/v0/management/config", "/v8/management/config", "/v0/management/plugins", "/v8/management/plugins"} {
|
|
req := httptest.NewRequest(http.MethodGet, route, nil)
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
if tc.authorized {
|
|
req.Header.Set("Authorization", "Bearer test-password")
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
s.engine.ServeHTTP(recorder, req)
|
|
if recorder.Code != tc.want {
|
|
t.Fatalf("%s: status=%d want=%d body=%s", route, recorder.Code, tc.want, recorder.Body.String())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestManagementV8IndependentContract(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
raw := "port: 8317\nrequest-retry: 3\ndebug: false\napi-keys: [client]\nremote-management: {secret-key: test-password}\n"
|
|
if err := os.WriteFile(path, []byte(raw), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := config.LoadConfig(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.AuthDir = t.TempDir()
|
|
h := management.NewHandler(cfg, path, nil)
|
|
h.SetLocalPassword("test-password")
|
|
s := &Server{cfg: cfg, engine: gin.New(), mgmt: h}
|
|
s.managementRoutesEnabled.Store(true)
|
|
s.registerManagementRoutes()
|
|
baseline, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
routes := make(map[string]bool)
|
|
for _, route := range s.engine.Routes() {
|
|
routes[route.Method+" "+route.Path] = true
|
|
}
|
|
for _, route := range []string{
|
|
"GET /v0/management/debug", "PUT /v0/management/request-retry", "GET /v0/management/auth-files",
|
|
"GET /v8/management/observability/logs", "GET /v8/management/observability/usage/queue",
|
|
"GET /v8/management/credentials", "POST /v8/management/credentials",
|
|
"GET /v8/management/oauth/auth-url", "POST /v8/management/oauth/import", "POST /v8/management/oauth/callback",
|
|
"GET /v8/management/credentials/quota/providers", "POST /v8/management/routing/cooldown/reset",
|
|
"POST /v8/management/plugins/store/:id/install", "DELETE /v8/management/plugins/:id",
|
|
} {
|
|
if !routes[route] {
|
|
t.Errorf("missing route %s", route)
|
|
}
|
|
}
|
|
request := func(method, url, body string, status int) string {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, url, strings.NewReader(body))
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("Authorization", "Bearer test-password")
|
|
response := httptest.NewRecorder()
|
|
s.engine.ServeHTTP(response, req)
|
|
if response.Code != status {
|
|
t.Fatalf("%s %s: status=%d body=%s", method, url, response.Code, response.Body.String())
|
|
}
|
|
return strings.TrimSpace(response.Body.String())
|
|
}
|
|
for _, legacy := range []string{"debug", "request-retry", "api-keys", "codex-api-key", "auth-files", "codex-auth-url", "oauth/providers/codex/auth-url", "plugins/test-plugin/config"} {
|
|
request(http.MethodGet, "/v8/management/"+legacy, "", http.StatusNotFound)
|
|
}
|
|
request(http.MethodPost, "/v8/management/oauth/providers/vertex/import", "", http.StatusNotFound)
|
|
for _, tc := range []struct {
|
|
method, url, body string
|
|
status int
|
|
}{
|
|
{http.MethodGet, "/v8/management/oauth/auth-url", `{"error":"provider is required"}`, http.StatusBadRequest},
|
|
{http.MethodGet, "/v8/management/oauth/auth-url?provider=%20", `{"error":"provider is required"}`, http.StatusBadRequest},
|
|
{http.MethodGet, "/v8/management/oauth/auth-url?provider=unknown", `{"error":"provider_not_found"}`, http.StatusNotFound},
|
|
{http.MethodPost, "/v8/management/oauth/import", `{"error":"provider is required"}`, http.StatusBadRequest},
|
|
{http.MethodPost, "/v8/management/oauth/import?provider=codex", `{"error":"provider_not_found"}`, http.StatusNotFound},
|
|
{http.MethodPost, "/v8/management/oauth/import?provider=vertex", `{"error":"file required"}`, http.StatusBadRequest},
|
|
{http.MethodPost, "/v0/management/vertex/import", `{"error":"file required"}`, http.StatusBadRequest},
|
|
} {
|
|
if got := request(tc.method, tc.url, "", tc.status); got != tc.body {
|
|
t.Fatalf("%s: body=%s, want %s", tc.url, got, tc.body)
|
|
}
|
|
}
|
|
for _, tc := range []struct{ url, provider string }{
|
|
{"/v8/management/oauth/auth-url?provider=codex", "codex"},
|
|
{"/v8/management/oauth/auth-url?provider=%20CLAUDE%20", "anthropic"},
|
|
{"/v0/management/codex-auth-url", "codex"},
|
|
{"/v0/management/anthropic-auth-url", "anthropic"},
|
|
} {
|
|
body := request(http.MethodGet, tc.url, "", http.StatusOK)
|
|
var login struct{ URL, State string }
|
|
if errDecode := json.Unmarshal([]byte(body), &login); errDecode != nil {
|
|
t.Fatal(errDecode)
|
|
}
|
|
t.Cleanup(func() { management.CancelOAuthSession(login.State) })
|
|
if login.URL == "" || !management.IsOAuthSessionPending(login.State, tc.provider) {
|
|
t.Fatalf("%s: invalid login response %s", tc.url, body)
|
|
}
|
|
if got := request(http.MethodGet, "/v8/management/oauth/status?state="+login.State, "", http.StatusOK); got != `{"status":"wait"}` {
|
|
t.Fatalf("pending login status=%s", got)
|
|
}
|
|
request(http.MethodDelete, "/v8/management/oauth/session?state="+login.State, "", http.StatusOK)
|
|
if management.IsOAuthSessionPending(login.State, tc.provider) {
|
|
t.Fatal("login session was not cancelled")
|
|
}
|
|
}
|
|
if got := request(http.MethodGet, "/v8/management/config/routing/retry/request-retry", "", http.StatusOK); got != "3" {
|
|
t.Fatalf("nested value = %s, want 3", got)
|
|
}
|
|
request(http.MethodPatch, "/v8/management/config", `{"request-retry":5}`, http.StatusBadRequest)
|
|
request(http.MethodPut, "/v8/management/config/routing/retry/request-retry", `{"value":5}`, http.StatusUnprocessableEntity)
|
|
unchanged, err := os.ReadFile(path)
|
|
if err != nil || string(unchanged) != string(baseline) {
|
|
t.Fatalf("reads or rejected writes migrated the file: %v", err)
|
|
}
|
|
request(http.MethodPut, "/v8/management/config/routing/retry/request-retry", `0`, http.StatusOK)
|
|
request(http.MethodPut, "/v0/management/request-retry", `{"value":2}`, http.StatusOK)
|
|
if got := request(http.MethodGet, "/v8/management/config/routing/retry/request-retry", "", http.StatusOK); got != "2" {
|
|
t.Fatalf("legacy write did not update the migrated field: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestManagementV8PluginOperationMigratesConfiguration(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.yaml")
|
|
raw := fmt.Sprintf("codex: {disable-codex-cloaking: true}\nremote-management: {secret-key: test-password}\nplugins:\n dir: %q\n configs: {test-plugin: {enabled: false}}\n", filepath.ToSlash(dir))
|
|
if err := os.WriteFile(path, []byte(raw), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := config.LoadConfig(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := management.NewHandler(cfg, path, nil)
|
|
h.SetLocalPassword("test-password")
|
|
reloads := make(chan *config.Config, 1)
|
|
h.SetConfigReloadHook(func(_ context.Context, next *config.Config) { reloads <- next })
|
|
s := &Server{cfg: cfg, engine: gin.New(), mgmt: h}
|
|
s.managementRoutesEnabled.Store(true)
|
|
s.registerManagementRoutes()
|
|
req := httptest.NewRequest(http.MethodDelete, "/v8/management/plugins/test-plugin", nil)
|
|
req.RemoteAddr = "127.0.0.1:1234"
|
|
req.Header.Set("Authorization", "Bearer test-password")
|
|
response := httptest.NewRecorder()
|
|
s.engine.ServeHTTP(response, req)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
saved, err := os.ReadFile(path)
|
|
if err != nil || !strings.Contains(string(saved), "config-version: 8") {
|
|
t.Fatalf("plugin operation did not migrate the config: %v", err)
|
|
}
|
|
select {
|
|
case next := <-reloads:
|
|
if next.ForAPIKey().Codex.DisableCodexCloaking || !next.Codex.DisableCodexCloaking {
|
|
t.Fatal("plugin operation published an incorrect configuration scope")
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("missing configuration reload")
|
|
}
|
|
}
|