Files
lingma-proxy-compose/internal/httpapi/server_test.go
GitHub Actions 450faefaf9 Add API key authentication for proxy endpoints.
Support multiple API keys from config, env, and CLI, enforce auth on non-public endpoints, and pass keys through remote deploy verification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:08:27 +08:00

450 lines
12 KiB
Go

package httpapi
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"lingma-ipc-proxy/internal/service"
)
func TestNormalizeOpenAIRequestCollectsSystemMessages(t *testing.T) {
req := openAIChatRequest{
Model: "test-model",
Messages: []rawMessage{
{Role: "system", Content: "You are concise."},
{Role: "user", Content: "Hello"},
{Role: "assistant", Content: "Hi"},
{Role: "system", Content: "Answer in Chinese."},
{Role: "tool", Content: "ignored"},
{Role: "user", Content: []any{
map[string]any{"type": "text", "text": "Follow up"},
}},
},
}
normalized, err := normalizeOpenAIRequest(req)
if err != nil {
t.Fatalf("normalizeOpenAIRequest() error = %v", err)
}
if normalized.Model != "test-model" {
t.Fatalf("model = %q", normalized.Model)
}
if normalized.System != "You are concise.\n\nAnswer in Chinese." {
t.Fatalf("system = %q", normalized.System)
}
if len(normalized.Messages) != 3 {
t.Fatalf("message count = %d", len(normalized.Messages))
}
if normalized.Messages[0].Role != "user" || normalized.Messages[0].Text != "Hello" {
t.Fatalf("first message = %+v", normalized.Messages[0])
}
if normalized.Messages[1].Role != "assistant" || normalized.Messages[1].Text != "Hi" {
t.Fatalf("second message = %+v", normalized.Messages[1])
}
if normalized.Messages[2].Role != "user" || normalized.Messages[2].Text != "Follow up" {
t.Fatalf("third message = %+v", normalized.Messages[2])
}
}
func TestCapabilitiesAdvertiseAgentCompatibility(t *testing.T) {
server := NewServer("", service.New(service.Config{
Model: "Qwen3-Coder",
Timeout: time.Second,
}))
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
rec := httptest.NewRecorder()
server.http.Handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
features, ok := body["features"].(map[string]any)
if !ok {
t.Fatalf("missing features: %#v", body)
}
for _, key := range []string{"tools", "tool_alias_mapping", "images", "local_image_paths", "image_auto_resize"} {
if features[key] != true {
t.Fatalf("feature %s = %#v", key, features[key])
}
}
}
func TestAuthAllowsPublicHealthWithoutAPIKey(t *testing.T) {
server := NewServer("", service.New(service.Config{
Model: "Qwen3-Coder",
Timeout: time.Second,
APIKeys: []string{"key-1", "key-2"},
}))
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
server.http.Handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
}
func TestAuthRejectsProtectedEndpointWithoutAPIKey(t *testing.T) {
server := NewServer("", service.New(service.Config{
Model: "Qwen3-Coder",
Timeout: time.Second,
APIKeys: []string{"key-1", "key-2"},
}))
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
rec := httptest.NewRecorder()
server.http.Handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "authentication_error") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestAuthAcceptsBearerAndXAPIKey(t *testing.T) {
server := NewServer("", service.New(service.Config{
Model: "Qwen3-Coder",
Timeout: time.Second,
APIKeys: []string{"key-1", "key-2"},
}))
bearerReq := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
bearerReq.Header.Set("Authorization", "Bearer key-2")
bearerRec := httptest.NewRecorder()
server.http.Handler.ServeHTTP(bearerRec, bearerReq)
if bearerRec.Code != http.StatusOK {
t.Fatalf("bearer status = %d body = %s", bearerRec.Code, bearerRec.Body.String())
}
xReq := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
xReq.Header.Set("x-api-key", "key-1")
xRec := httptest.NewRecorder()
server.http.Handler.ServeHTTP(xRec, xReq)
if xRec.Code != http.StatusOK {
t.Fatalf("x-api-key status = %d body = %s", xRec.Code, xRec.Body.String())
}
}
func TestAnthropicAuthErrorShape(t *testing.T) {
server := NewServer("", service.New(service.Config{
Model: "Qwen3-Coder",
Timeout: time.Second,
APIKeys: []string{"key-1"},
}))
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"kmodel","messages":[{"role":"user","content":"hello"}],"max_tokens":16}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
server.http.Handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"type":"error"`) {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestNormalizeOpenAIRequestRejectsMissingUserAndAssistantMessages(t *testing.T) {
req := openAIChatRequest{
Model: "test-model",
Messages: []rawMessage{
{Role: "system", Content: "Only system"},
{Role: "tool", Content: "ignored"},
},
}
_, err := normalizeOpenAIRequest(req)
if err == nil {
t.Fatal("expected error for request without user or assistant messages")
}
}
func TestNormalizeAnthropicRequestExtractsStructuredText(t *testing.T) {
req := anthropicRequest{
Model: "test-model",
System: []any{map[string]any{"type": "text", "text": "System prompt"}},
Messages: []rawMessage{
{
Role: "user",
Content: []any{
map[string]any{"type": "text", "text": "Hello"},
},
},
{
Role: "assistant",
Content: []any{
map[string]any{"type": "text", "text": "Hi"},
},
},
{
Role: "metadata",
Content: []any{
map[string]any{"type": "text", "text": "ignored"},
},
},
},
}
normalized, err := normalizeAnthropicRequest(req)
if err != nil {
t.Fatalf("normalizeAnthropicRequest() error = %v", err)
}
if normalized.Model != "test-model" {
t.Fatalf("model = %q", normalized.Model)
}
if normalized.System != "System prompt" {
t.Fatalf("system = %q", normalized.System)
}
if len(normalized.Messages) != 2 {
t.Fatalf("message count = %d", len(normalized.Messages))
}
if normalized.Messages[0].Role != "user" || normalized.Messages[0].Text != "Hello" {
t.Fatalf("first message = %+v", normalized.Messages[0])
}
if normalized.Messages[1].Role != "assistant" || normalized.Messages[1].Text != "Hi" {
t.Fatalf("second message = %+v", normalized.Messages[1])
}
}
func TestNormalizeAnthropicRequestRejectsEmptyMessages(t *testing.T) {
req := anthropicRequest{
Model: "test-model",
Messages: []rawMessage{
{Role: "user", Content: ""},
{Role: "assistant", Content: nil},
},
}
_, err := normalizeAnthropicRequest(req)
if err == nil {
t.Fatal("expected error for request without usable messages")
}
}
func TestAnthropicHostedWebSearchCall(t *testing.T) {
req := anthropicRequest{
Model: "Kimi-K2.6",
Tools: []any{
map[string]any{
"name": "web_search",
"type": "web_search_20250305",
},
},
ToolChoice: map[string]any{
"type": "tool",
"name": "web_search",
},
Messages: []rawMessage{{
Role: "user",
Content: []any{
map[string]any{
"type": "text",
"text": "Perform a web search for the query: Hermes agent web UI documentation",
},
},
}},
}
call, ok := anthropicHostedWebSearchCall(req)
if !ok {
t.Fatal("expected hosted web_search tool call")
}
if call.Name != "web_search" {
t.Fatalf("tool name = %q", call.Name)
}
if call.Arguments["query"] != "Hermes agent web UI documentation" {
t.Fatalf("query = %#v", call.Arguments["query"])
}
if !strings.HasPrefix(call.ID, "toolu_") {
t.Fatalf("id = %q", call.ID)
}
}
func TestAnthropicHostedWebSearchCallIgnoresRegularClientWebSearch(t *testing.T) {
req := anthropicRequest{
Tools: []any{
map[string]any{
"name": "web_search",
"input_schema": map[string]any{
"type": "object",
},
},
},
Messages: []rawMessage{{
Role: "user",
Content: "Perform a web search for the query: Lingma",
}},
}
if _, ok := anthropicHostedWebSearchCall(req); ok {
t.Fatal("regular client web_search should stay in prompt tool emulation")
}
}
func TestAnthropicHostedWebSearchCallIgnoresToolResultFollowup(t *testing.T) {
req := anthropicRequest{
Tools: []any{
map[string]any{
"name": "web_search",
"type": "web_search_20250305",
},
},
ToolChoice: map[string]any{
"type": "tool",
"name": "web_search",
},
Messages: []rawMessage{{
Role: "user",
Content: []any{
map[string]any{
"type": "tool_result",
"tool_use_id": "toolu_123",
"content": "result",
},
},
}},
}
if _, ok := anthropicHostedWebSearchCall(req); ok {
t.Fatal("hosted web_search should not short-circuit after a tool_result")
}
}
func TestAnthropicCountTokensEndpoint(t *testing.T) {
server := NewServer("", service.New(service.Config{
Model: "Qwen3-Coder",
Timeout: time.Second,
}))
req := httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", strings.NewReader(`{
"model":"kmodel",
"max_tokens":128,
"system":"You are concise.",
"messages":[{"role":"user","content":"hello"}],
"tools":[{"name":"read_file","input_schema":{"type":"object","properties":{"file_path":{"type":"string"}},"required":["file_path"]}}]
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
server.http.Handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["input_tokens"].(float64) <= 0 {
t.Fatalf("input_tokens = %#v", body["input_tokens"])
}
}
func TestDiscoveryCompatibilityEndpoints(t *testing.T) {
server := NewServer("", service.New(service.Config{
Model: "Qwen3-Coder",
Timeout: time.Second,
}))
cases := []string{
"/version",
"/props",
"/v1/props",
}
for _, path := range cases {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
server.http.Handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status = %d body = %s", path, rec.Code, rec.Body.String())
}
}
}
func TestToolStreamFilterStreamsNormalTextWithTools(t *testing.T) {
filter := newToolStreamFilter(true)
var chunks []string
chunks = append(chunks, filter.Push(strings.Repeat("你", 120))...)
chunks = append(chunks, filter.Push("后续内容")...)
chunks = append(chunks, filter.Flush()...)
out := strings.Join(chunks, "")
if !strings.Contains(out, "后续内容") {
t.Fatalf("streamed text = %q", out)
}
}
func TestToolStreamFilterBuffersActionBlock(t *testing.T) {
filter := newToolStreamFilter(true)
var chunks []string
chunks = append(chunks, filter.Push("```json ")...)
chunks = append(chunks, filter.Push("action\n{\"tool\":\"Bash\",\"parameters\":{\"command\":\"pwd\"}}\n```")...)
chunks = append(chunks, filter.Flush()...)
if len(chunks) != 0 {
t.Fatalf("unexpected leaked action chunks: %#v", chunks)
}
}
func TestParseImageURLReadsLocalFileURL(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sample.jpg")
data := []byte{0xff, 0xd8, 0xff, 0xd9}
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
img := parseImageURL("file://" + path)
if img == nil {
t.Fatal("expected image")
}
if img.MediaType != "image/jpeg" {
t.Fatalf("media type = %q", img.MediaType)
}
if img.Data != base64.StdEncoding.EncodeToString(data) {
t.Fatalf("data = %q", img.Data)
}
}
func TestParseImageURLReadsAbsoluteLocalPath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sample.png")
data := []byte{0x89, 0x50, 0x4e, 0x47}
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
img := parseImageURL(path)
if img == nil {
t.Fatal("expected image")
}
if img.MediaType != "image/png" {
t.Fatalf("media type = %q", img.MediaType)
}
if img.Data != base64.StdEncoding.EncodeToString(data) {
t.Fatalf("data = %q", img.Data)
}
}
func TestSanitizeRecordedBodyRedactsImagePayloads(t *testing.T) {
raw := []byte(`{"messages":[{"content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,` + strings.Repeat("a", 8192) + `"}}]}]}`)
got := sanitizeRecordedBody(raw)
if strings.Contains(got, "data:image/png;base64") {
t.Fatalf("image payload was not redacted: %s", got)
}
if !strings.Contains(got, "[image payload redacted") {
t.Fatalf("missing redaction marker: %s", got)
}
}