mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 22:14:37 +00:00
ACP providers use api_base to store an executable command/path, not a URL. A previous SSRF-hardening refactor incorrectly classified ACP as a local-URL provider type, causing create/update to reject valid ACP configs with "provider URL must use http or https scheme". Changes: - Remove ProviderACP from localURLProviderTypes. - Add validateACPExecutablePath mirroring the runtime registration logic in cmd/gateway_providers.go (allows empty, claude/codex/gemini, or absolute path; rejects URLs and relative paths). - Route ACP through the new validator inside validateProviderURL. - Add unit tests for validateACPExecutablePath. - Update existing local-type tests that assumed ACP was URL-based. - Add create/update HTTP handler regression tests for ACP binary, empty binary, and URL rejection. Bug-first verification: without the fix, ACP api_base values like "gemini" or absolute paths fail URL validation, while URLs are incorrectly accepted; with the fix the behavior is reversed to match the intended executable-path model. Fixes nextlevelbuilder/goclaw#1481.
812 lines
28 KiB
Go
812 lines
28 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
func TestProvidersHandlerRegisterInMemoryAppliesCodexPoolDefaults(t *testing.T) {
|
|
providerReg := providers.NewRegistry(nil)
|
|
handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "")
|
|
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
TenantID: uuid.New(),
|
|
Name: "openai-codex",
|
|
ProviderType: store.ProviderChatGPTOAuth,
|
|
APIKey: "token",
|
|
Enabled: true,
|
|
Settings: json.RawMessage(`{
|
|
"codex_pool": {
|
|
"strategy": "round_robin",
|
|
"extra_provider_names": ["codex-work"]
|
|
}
|
|
}`),
|
|
}
|
|
|
|
handler.registerInMemory(provider)
|
|
|
|
runtimeProvider, err := providerReg.GetForTenant(provider.TenantID, provider.Name)
|
|
if err != nil {
|
|
t.Fatalf("GetForTenant() error = %v", err)
|
|
}
|
|
codex, ok := runtimeProvider.(*providers.CodexProvider)
|
|
if !ok {
|
|
t.Fatalf("runtime provider = %T, want *providers.CodexProvider", runtimeProvider)
|
|
}
|
|
defaults := codex.RoutingDefaults()
|
|
if defaults == nil {
|
|
t.Fatal("RoutingDefaults() = nil, want defaults")
|
|
}
|
|
if defaults.Strategy != store.ChatGPTOAuthStrategyRoundRobin {
|
|
t.Fatalf("Strategy = %q, want %q", defaults.Strategy, store.ChatGPTOAuthStrategyRoundRobin)
|
|
}
|
|
if len(defaults.ExtraProviderNames) != 1 || defaults.ExtraProviderNames[0] != "codex-work" {
|
|
t.Fatalf("ExtraProviderNames = %#v, want [\"codex-work\"]", defaults.ExtraProviderNames)
|
|
}
|
|
}
|
|
|
|
// TestProvidersHandlerRegisterInMemoryUsesDBNameForAnthropic guards the onboarding verify flow:
|
|
// when an Anthropic provider is created via HTTP with a custom name, the in-memory registry
|
|
// must key the provider by that DB name — not the hardcoded "anthropic" default. Otherwise
|
|
// handleVerifyProvider's GetForTenant(p.TenantID, p.Name) lookup fails with "provider not registered".
|
|
// See commit 7fcf0327 for the matching fix on the startup path (cmd/gateway_providers.go).
|
|
func TestProvidersHandlerRegisterInMemoryUsesDBNameForAnthropic(t *testing.T) {
|
|
providerReg := providers.NewRegistry(nil)
|
|
handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "")
|
|
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
TenantID: uuid.New(),
|
|
Name: "my-anthropic",
|
|
ProviderType: store.ProviderAnthropicNative,
|
|
APIKey: "sk-ant-test",
|
|
Enabled: true,
|
|
}
|
|
|
|
handler.registerInMemory(provider)
|
|
|
|
got, err := providerReg.GetForTenant(provider.TenantID, provider.Name)
|
|
if err != nil {
|
|
t.Fatalf("GetForTenant(%q) error = %v, want provider registered under DB name", provider.Name, err)
|
|
}
|
|
if got.Name() != provider.Name {
|
|
t.Fatalf("Name() = %q, want %q", got.Name(), provider.Name)
|
|
}
|
|
|
|
// Negative: the hardcoded default "anthropic" must NOT be registered when the user chose a different name.
|
|
if _, err := providerReg.GetForTenant(provider.TenantID, "anthropic"); err == nil {
|
|
t.Fatal("GetForTenant(\"anthropic\") succeeded, want not-found — provider should only live under its DB name")
|
|
}
|
|
}
|
|
|
|
func TestProvidersHandlerRegisterInMemoryMiniMaxUsesM3Default(t *testing.T) {
|
|
providerReg := providers.NewRegistry(nil)
|
|
handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "")
|
|
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
TenantID: uuid.New(),
|
|
Name: "minimax",
|
|
ProviderType: store.ProviderMiniMax,
|
|
APIKey: "token",
|
|
Enabled: true,
|
|
}
|
|
|
|
handler.registerInMemory(provider)
|
|
|
|
runtimeProvider, err := providerReg.GetForTenant(provider.TenantID, provider.Name)
|
|
if err != nil {
|
|
t.Fatalf("GetForTenant() error = %v", err)
|
|
}
|
|
if got := runtimeProvider.DefaultModel(); got != "MiniMax-M3" {
|
|
t.Fatalf("DefaultModel() = %q, want MiniMax-M3", got)
|
|
}
|
|
openai, ok := runtimeProvider.(*providers.OpenAIProvider)
|
|
if !ok {
|
|
t.Fatalf("runtime provider = %T, want *providers.OpenAIProvider", runtimeProvider)
|
|
}
|
|
if got := openai.APIBase(); got != "https://api.minimax.io/v1" {
|
|
t.Fatalf("APIBase() = %q, want MiniMax default base", got)
|
|
}
|
|
}
|
|
|
|
func TestProvidersHandlerRegisterInMemoryMiniMaxUsesOpenAIChatCompletionsPath(t *testing.T) {
|
|
var capturedPath string
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
capturedPath = r.URL.Path
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"choices": []map[string]any{
|
|
{
|
|
"message": map[string]string{"content": "ok"},
|
|
"finish_reason": "stop",
|
|
},
|
|
},
|
|
})
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
|
|
providerReg := providers.NewRegistry(nil)
|
|
handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "")
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
TenantID: uuid.New(),
|
|
Name: "minimax",
|
|
ProviderType: store.ProviderMiniMax,
|
|
APIBase: upstream.URL,
|
|
APIKey: "token",
|
|
Enabled: true,
|
|
}
|
|
|
|
handler.registerInMemory(provider)
|
|
|
|
runtimeProvider, err := providerReg.GetForTenant(provider.TenantID, provider.Name)
|
|
if err != nil {
|
|
t.Fatalf("GetForTenant() error = %v", err)
|
|
}
|
|
_, err = runtimeProvider.Chat(context.Background(), providers.ChatRequest{
|
|
Messages: []providers.Message{{Role: "user", Content: "hi"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Chat() error = %v", err)
|
|
}
|
|
if capturedPath != "/chat/completions" {
|
|
t.Fatalf("captured path = %q, want /chat/completions", capturedPath)
|
|
}
|
|
}
|
|
|
|
func TestProvidersHandlerRegisterInMemoryZaiUsesGLM52Default(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
providerType string
|
|
wantBase string
|
|
}{
|
|
{providerType: store.ProviderZai, wantBase: "https://api.z.ai/api/paas/v4"},
|
|
{providerType: store.ProviderZaiCoding, wantBase: "https://api.z.ai/api/coding/paas/v4"},
|
|
} {
|
|
t.Run(tt.providerType, func(t *testing.T) {
|
|
providerReg := providers.NewRegistry(nil)
|
|
handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "")
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
TenantID: uuid.New(),
|
|
Name: tt.providerType,
|
|
ProviderType: tt.providerType,
|
|
APIKey: "token",
|
|
Enabled: true,
|
|
}
|
|
|
|
handler.registerInMemory(provider)
|
|
|
|
runtimeProvider, err := providerReg.GetForTenant(provider.TenantID, provider.Name)
|
|
if err != nil {
|
|
t.Fatalf("GetForTenant() error = %v", err)
|
|
}
|
|
if got := runtimeProvider.DefaultModel(); got != "glm-5.2" {
|
|
t.Fatalf("DefaultModel() = %q, want glm-5.2", got)
|
|
}
|
|
openai, ok := runtimeProvider.(*providers.OpenAIProvider)
|
|
if !ok {
|
|
t.Fatalf("runtime provider = %T, want *providers.OpenAIProvider", runtimeProvider)
|
|
}
|
|
if got := openai.APIBase(); got != tt.wantBase {
|
|
t.Fatalf("APIBase() = %q, want %q", got, tt.wantBase)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestProvidersHandlerRegisterInMemoryUsesDBNameForClaudeCLI mirrors the Anthropic guard for Claude CLI.
|
|
// Custom-named CLI providers must be registered under their DB name to be locatable via verify.
|
|
func TestProvidersHandlerRegisterInMemoryUsesDBNameForClaudeCLI(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("skipping on Windows: shell-script fake binary not portable")
|
|
}
|
|
providerReg := providers.NewRegistry(nil)
|
|
handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "")
|
|
|
|
// Create a fake executable so exec.LookPath succeeds — the binary-existence guard
|
|
// added in registerInMemory (parity with startup path) would otherwise skip registration
|
|
// when the test runner has no real `claude` binary in PATH.
|
|
fakeCLI := writeFakeClaudeBinary(t)
|
|
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
TenantID: uuid.New(),
|
|
Name: "claude-max",
|
|
ProviderType: store.ProviderClaudeCLI,
|
|
APIBase: fakeCLI,
|
|
Enabled: true,
|
|
}
|
|
|
|
handler.registerInMemory(provider)
|
|
|
|
got, err := providerReg.GetForTenant(provider.TenantID, provider.Name)
|
|
if err != nil {
|
|
t.Fatalf("GetForTenant(%q) error = %v, want provider registered under DB name", provider.Name, err)
|
|
}
|
|
if got.Name() != provider.Name {
|
|
t.Fatalf("Name() = %q, want %q", got.Name(), provider.Name)
|
|
}
|
|
|
|
// Negative: the hardcoded default "claude-cli" must NOT be registered when the user chose a different name.
|
|
if _, err := providerReg.GetForTenant(provider.TenantID, "claude-cli"); err == nil {
|
|
t.Fatal("GetForTenant(\"claude-cli\") succeeded, want not-found — provider should only live under its DB name")
|
|
}
|
|
}
|
|
|
|
// TestProvidersHandlerRegisterInMemorySkipsClaudeCLIWhenBinaryMissing guards the binary-existence check
|
|
// added to mirror cmd/gateway_providers.go. If the configured CLI path does not resolve via exec.LookPath,
|
|
// registerInMemory must return without registering — otherwise verify would succeed on a provider that
|
|
// cannot actually spawn the CLI.
|
|
func TestProvidersHandlerRegisterInMemorySkipsClaudeCLIWhenBinaryMissing(t *testing.T) {
|
|
providerReg := providers.NewRegistry(nil)
|
|
handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "")
|
|
|
|
// Absolute path to a file that cannot exist — exec.LookPath must fail.
|
|
missingPath := filepath.Join(t.TempDir(), "definitely-not-claude")
|
|
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
TenantID: uuid.New(),
|
|
Name: "claude-broken",
|
|
ProviderType: store.ProviderClaudeCLI,
|
|
APIBase: missingPath,
|
|
Enabled: true,
|
|
}
|
|
|
|
handler.registerInMemory(provider)
|
|
|
|
if _, err := providerReg.GetForTenant(provider.TenantID, provider.Name); err == nil {
|
|
t.Fatal("GetForTenant succeeded, want not-found — registration should be skipped when binary missing")
|
|
}
|
|
}
|
|
|
|
// TestProvidersHandlerRegisterInMemoryAnthropicUsesModelRegistry verifies the HTTP onboarding path
|
|
// wires the forward-compat ModelRegistry into Anthropic providers, matching the startup path behavior
|
|
// (cmd/gateway_providers.go:349). Without this, model alias resolution and token counting fall back
|
|
// to static defaults — affects cost accounting and forward-compat for new model IDs.
|
|
func TestProvidersHandlerRegisterInMemoryAnthropicUsesModelRegistry(t *testing.T) {
|
|
providerReg := providers.NewRegistry(nil)
|
|
handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "")
|
|
|
|
modelReg := providers.NewInMemoryRegistry()
|
|
handler.SetModelRegistry(modelReg)
|
|
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
TenantID: uuid.New(),
|
|
Name: "my-anthropic",
|
|
ProviderType: store.ProviderAnthropicNative,
|
|
APIKey: "sk-ant-test",
|
|
Enabled: true,
|
|
}
|
|
|
|
handler.registerInMemory(provider)
|
|
|
|
got, err := providerReg.GetForTenant(provider.TenantID, provider.Name)
|
|
if err != nil {
|
|
t.Fatalf("GetForTenant() error = %v", err)
|
|
}
|
|
|
|
// Reflection probe: the AnthropicProvider stores its registry in an unexported "registry" field.
|
|
// No public getter exists, but this wiring is small enough that a structural check is warranted —
|
|
// the alternative is no coverage, and the field name is stable (see internal/providers/anthropic.go).
|
|
v := reflect.ValueOf(got).Elem().FieldByName("registry")
|
|
if !v.IsValid() {
|
|
t.Fatal("AnthropicProvider.registry field not found — implementation drift")
|
|
}
|
|
// Field is an interface; .IsNil() panics on non-interface kinds, so guard.
|
|
if v.Kind() == reflect.Interface && v.IsNil() {
|
|
t.Fatal("AnthropicProvider.registry is nil, want ModelRegistry set via SetModelRegistry")
|
|
}
|
|
}
|
|
|
|
func TestProvidersHandlerRegisterInMemoryDashScopeProxyKeepsCacheDetection(t *testing.T) {
|
|
providerReg := providers.NewRegistry(nil)
|
|
handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "")
|
|
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
TenantID: uuid.New(),
|
|
Name: "qwen-richard",
|
|
ProviderType: store.ProviderDashScope,
|
|
APIBase: "https://proxy.internal/v1",
|
|
APIKey: "sk-test",
|
|
Enabled: true,
|
|
}
|
|
|
|
handler.registerInMemory(provider)
|
|
|
|
got, err := providerReg.GetForTenant(provider.TenantID, provider.Name)
|
|
if err != nil {
|
|
t.Fatalf("GetForTenant() error = %v", err)
|
|
}
|
|
assertRuntimeProviderWrapsDashScopeSystem(t, got, "qwen3.6-plus")
|
|
}
|
|
|
|
func TestProvidersHandlerRegisterInMemoryBailianProxyKeepsCacheDetection(t *testing.T) {
|
|
providerReg := providers.NewRegistry(nil)
|
|
handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "")
|
|
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
TenantID: uuid.New(),
|
|
Name: "qwen-richard",
|
|
ProviderType: store.ProviderBailian,
|
|
APIBase: "https://proxy.internal/v1",
|
|
APIKey: "sk-test",
|
|
Enabled: true,
|
|
}
|
|
|
|
handler.registerInMemory(provider)
|
|
|
|
got, err := providerReg.GetForTenant(provider.TenantID, provider.Name)
|
|
if err != nil {
|
|
t.Fatalf("GetForTenant() error = %v", err)
|
|
}
|
|
assertRuntimeProviderWrapsDashScopeSystem(t, got, "qwen3.6-plus")
|
|
}
|
|
|
|
func assertRuntimeProviderWrapsDashScopeSystem(t *testing.T, runtimeProvider providers.Provider, model string) {
|
|
t.Helper()
|
|
|
|
bodyBuilder, ok := runtimeProvider.(interface {
|
|
BuildRequestBodyForTest(string, providers.ChatRequest, bool) map[string]any
|
|
})
|
|
if !ok {
|
|
t.Fatalf("runtime provider = %T, want BuildRequestBodyForTest", runtimeProvider)
|
|
}
|
|
|
|
body := bodyBuilder.BuildRequestBodyForTest(model, providers.ChatRequest{
|
|
Messages: []providers.Message{
|
|
{Role: "system", Content: "Stable prefix\n" + providers.CacheBoundaryMarker + "\nDynamic suffix"},
|
|
{Role: "user", Content: "Hi"},
|
|
},
|
|
}, false)
|
|
msgs := body["messages"].([]map[string]any)
|
|
if _, ok := msgs[0]["content"].([]map[string]any); !ok {
|
|
t.Fatalf("system content = %T, want DashScope cache blocks", msgs[0]["content"])
|
|
}
|
|
}
|
|
|
|
// writeFakeClaudeBinary creates an executable stub in a temp dir so exec.LookPath resolves it.
|
|
// Returns the absolute path suitable for use as APIBase on a Claude CLI provider record.
|
|
func writeFakeClaudeBinary(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "claude-stub")
|
|
// Minimal POSIX script — registerInMemory only cares that the path resolves, not what it does.
|
|
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
|
t.Fatalf("write fake binary: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func setupProvidersAdminToken(t *testing.T) string {
|
|
t.Helper()
|
|
token := "system-admin-key"
|
|
setupTestCache(t, map[string]*store.APIKeyData{
|
|
crypto.HashAPIKey(token): {
|
|
ID: uuid.New(),
|
|
Scopes: []string{"operator.admin"},
|
|
},
|
|
})
|
|
return token
|
|
}
|
|
|
|
func TestProvidersHandlerCreateRejectsIncompatibleEmbeddingDimensions(t *testing.T) {
|
|
token := setupProvidersAdminToken(t)
|
|
providerStore := newMockProviderStore()
|
|
handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "")
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
body := `{
|
|
"name": "voyage",
|
|
"provider_type": "openai_compat",
|
|
"api_base": "https://api.voyageai.com/v1",
|
|
"enabled": true,
|
|
"settings": {
|
|
"embedding": {
|
|
"enabled": true,
|
|
"model": "voyage-4-nano",
|
|
"dimensions": 2048
|
|
}
|
|
}
|
|
}`
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/providers", bytes.NewBufferString(body))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
|
}
|
|
if !strings.Contains(w.Body.String(), "1536") {
|
|
t.Fatalf("response body = %q, want mention of 1536", w.Body.String())
|
|
}
|
|
if len(providerStore.providers) != 0 {
|
|
t.Fatalf("provider store mutated on invalid create: %#v", providerStore.providers)
|
|
}
|
|
}
|
|
|
|
func TestProvidersHandlerCreateAllows1536EmbeddingDimensions(t *testing.T) {
|
|
token := setupProvidersAdminToken(t)
|
|
providerStore := newMockProviderStore()
|
|
handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "")
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
body := `{
|
|
"name": "gemini-emb",
|
|
"provider_type": "gemini_native",
|
|
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
"api_key": "token",
|
|
"enabled": true,
|
|
"settings": {
|
|
"embedding": {
|
|
"enabled": true,
|
|
"model": "gemini-embedding-001",
|
|
"dimensions": 1536
|
|
}
|
|
}
|
|
}`
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/providers", bytes.NewBufferString(body))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
|
|
}
|
|
if len(providerStore.providers) != 1 {
|
|
t.Fatalf("provider count = %d, want 1", len(providerStore.providers))
|
|
}
|
|
}
|
|
|
|
func TestProvidersHandlerCreateAllowsClaudeCLIExecutablePath(t *testing.T) {
|
|
token := setupProvidersAdminToken(t)
|
|
providerStore := newMockProviderStore()
|
|
handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "")
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
body := map[string]any{
|
|
"name": "claude-local",
|
|
"provider_type": store.ProviderClaudeCLI,
|
|
"api_base": writeFakeClaudeBinary(t),
|
|
"enabled": true,
|
|
}
|
|
rawBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/providers", bytes.NewReader(rawBody))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
|
|
}
|
|
if got := providerStore.providers["claude-local"].APIBase; got == "" || !filepath.IsAbs(got) {
|
|
t.Fatalf("stored Claude CLI api_base = %q, want absolute executable path", got)
|
|
}
|
|
}
|
|
|
|
func TestProvidersHandlerUpdateAllowsClaudeCLIExecutablePath(t *testing.T) {
|
|
token := setupProvidersAdminToken(t)
|
|
providerStore := newMockProviderStore()
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
Name: "claude-local",
|
|
ProviderType: store.ProviderClaudeCLI,
|
|
APIBase: "claude",
|
|
Enabled: true,
|
|
}
|
|
if err := providerStore.CreateProvider(context.Background(), provider); err != nil {
|
|
t.Fatalf("CreateProvider() error = %v", err)
|
|
}
|
|
|
|
handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "")
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
nextPath := writeFakeClaudeBinary(t)
|
|
body := map[string]any{"api_base": nextPath}
|
|
rawBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/providers/"+provider.ID.String(), bytes.NewReader(rawBody))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
current, err := providerStore.GetProvider(context.Background(), provider.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetProvider() error = %v", err)
|
|
}
|
|
if current.APIBase != nextPath {
|
|
t.Fatalf("api_base = %q, want %q", current.APIBase, nextPath)
|
|
}
|
|
}
|
|
|
|
// TestProvidersHandlerCreateAllowsACPExecutablePath guards the fix for issue #1481:
|
|
// ACP provider api_base is an executable command/path, not a URL, and must not be
|
|
// rejected by the SSRF URL validator.
|
|
func TestProvidersHandlerCreateAllowsACPExecutablePath(t *testing.T) {
|
|
token := setupProvidersAdminToken(t)
|
|
providerStore := newMockProviderStore()
|
|
handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "")
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
body := map[string]any{
|
|
"name": "acp-gemini",
|
|
"provider_type": store.ProviderACP,
|
|
"api_base": writeFakeClaudeBinary(t), // any absolute path is acceptable
|
|
"enabled": true,
|
|
}
|
|
rawBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/providers", bytes.NewReader(rawBody))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
|
|
}
|
|
if got := providerStore.providers["acp-gemini"].APIBase; got == "" || !filepath.IsAbs(got) {
|
|
t.Fatalf("stored ACP api_base = %q, want absolute executable path", got)
|
|
}
|
|
}
|
|
|
|
// TestProvidersHandlerCreateAllowsEmptyACPBinary confirms that an empty api_base
|
|
// is accepted at create time; the runtime path may fall back to config/env.
|
|
func TestProvidersHandlerCreateAllowsEmptyACPBinary(t *testing.T) {
|
|
token := setupProvidersAdminToken(t)
|
|
providerStore := newMockProviderStore()
|
|
handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "")
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
body := map[string]any{
|
|
"name": "acp-empty",
|
|
"provider_type": store.ProviderACP,
|
|
"enabled": true,
|
|
}
|
|
rawBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/providers", bytes.NewReader(rawBody))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestProvidersHandlerCreateRejectsACPURL ensures ACP api_base cannot be a URL
|
|
// (it must be an executable path/command).
|
|
func TestProvidersHandlerCreateRejectsACPURL(t *testing.T) {
|
|
token := setupProvidersAdminToken(t)
|
|
providerStore := newMockProviderStore()
|
|
handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "")
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
body := map[string]any{
|
|
"name": "acp-url",
|
|
"provider_type": store.ProviderACP,
|
|
"api_base": "http://127.0.0.1:9090",
|
|
"enabled": true,
|
|
}
|
|
rawBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/providers", bytes.NewReader(rawBody))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestProvidersHandlerUpdateAllowsACPExecutablePath guards update-time validation
|
|
// for ACP providers.
|
|
func TestProvidersHandlerUpdateAllowsACPExecutablePath(t *testing.T) {
|
|
token := setupProvidersAdminToken(t)
|
|
providerStore := newMockProviderStore()
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
Name: "acp-local",
|
|
ProviderType: store.ProviderACP,
|
|
APIBase: "gemini",
|
|
Enabled: true,
|
|
}
|
|
if err := providerStore.CreateProvider(context.Background(), provider); err != nil {
|
|
t.Fatalf("CreateProvider() error = %v", err)
|
|
}
|
|
|
|
handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "")
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
nextPath := writeFakeClaudeBinary(t)
|
|
body := map[string]any{"api_base": nextPath}
|
|
rawBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/providers/"+provider.ID.String(), bytes.NewReader(rawBody))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
current, err := providerStore.GetProvider(context.Background(), provider.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetProvider() error = %v", err)
|
|
}
|
|
if current.APIBase != nextPath {
|
|
t.Fatalf("api_base = %q, want %q", current.APIBase, nextPath)
|
|
}
|
|
}
|
|
|
|
// TestProvidersHandlerUpdateRejectsACPURL ensures update-time validation rejects
|
|
// a URL-valued api_base for ACP providers.
|
|
func TestProvidersHandlerUpdateRejectsACPURL(t *testing.T) {
|
|
token := setupProvidersAdminToken(t)
|
|
providerStore := newMockProviderStore()
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
Name: "acp-local",
|
|
ProviderType: store.ProviderACP,
|
|
APIBase: "gemini",
|
|
Enabled: true,
|
|
}
|
|
if err := providerStore.CreateProvider(context.Background(), provider); err != nil {
|
|
t.Fatalf("CreateProvider() error = %v", err)
|
|
}
|
|
|
|
handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "")
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
body := map[string]any{"api_base": "http://127.0.0.1:9090"}
|
|
rawBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/providers/"+provider.ID.String(), bytes.NewReader(rawBody))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestProvidersHandlerUpdateMarshalsSettingsForStore(t *testing.T) {
|
|
token := setupProvidersAdminToken(t)
|
|
providerStore := newMockProviderStore()
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
Name: "openai",
|
|
ProviderType: store.ProviderOpenAICompat,
|
|
Enabled: true,
|
|
Settings: json.RawMessage(`{}`),
|
|
}
|
|
if err := providerStore.CreateProvider(context.Background(), provider); err != nil {
|
|
t.Fatalf("CreateProvider() error = %v", err)
|
|
}
|
|
|
|
handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "")
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
body := `{"settings":{"timeout_seconds":60}}`
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/providers/"+provider.ID.String(), bytes.NewBufferString(body))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
current, err := providerStore.GetProvider(context.Background(), provider.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetProvider() error = %v", err)
|
|
}
|
|
if got, want := string(current.Settings), `{"timeout_seconds":60}`; got != want {
|
|
t.Fatalf("settings = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestProvidersHandlerUpdateRejectsIncompatibleEmbeddingDimensions(t *testing.T) {
|
|
token := setupProvidersAdminToken(t)
|
|
providerStore := newMockProviderStore()
|
|
provider := &store.LLMProviderData{
|
|
BaseModel: store.BaseModel{ID: uuid.New()},
|
|
Name: "voyage",
|
|
ProviderType: store.ProviderOpenAICompat,
|
|
APIBase: "https://api.voyageai.com/v1",
|
|
Enabled: true,
|
|
Settings: json.RawMessage(`{"embedding":{"enabled":true,"model":"voyage-4-nano","dimensions":1536}}`),
|
|
}
|
|
if err := providerStore.CreateProvider(context.Background(), provider); err != nil {
|
|
t.Fatalf("CreateProvider() error = %v", err)
|
|
}
|
|
|
|
handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "")
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
body := `{
|
|
"settings": {
|
|
"embedding": {
|
|
"enabled": true,
|
|
"model": "voyage-4-nano",
|
|
"dimensions": 2048
|
|
}
|
|
}
|
|
}`
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/providers/"+provider.ID.String(), bytes.NewBufferString(body))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
|
}
|
|
current, err := providerStore.GetProvider(context.Background(), provider.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetProvider() error = %v", err)
|
|
}
|
|
es := store.ParseEmbeddingSettings(current.Settings)
|
|
if es == nil || es.Dimensions != 1536 {
|
|
t.Fatalf("embedding dimensions = %+v, want 1536 preserved", es)
|
|
}
|
|
}
|