Files
fcy 6063d975e8 fix(http): restore ACP executable-path validation, bypass SSRF URL check (#1481)
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.
2026-09-27 12:42:14 +08:00

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)
}
}