Files
fire-safety-ymd/internal/handler/dashscope_chat_test.go
2026-09-05 17:36:23 +08:00

348 lines
16 KiB
Go

package handler
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"fire-safety-ymd/internal/service"
)
const testDashScopeAppID = "fire-safety-test-app"
func TestNewDashScopeChatHandlerRejectsUnsafeOptions(t *testing.T) {
tests := []DashScopeChatOptions{
{AppID: "", AuthToken: testChatToken, MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: "app/other", AuthToken: testChatToken, MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: "app.other", AuthToken: testChatToken, MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: "short", MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: "", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: "delivered\tkey", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: "delivered密钥", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: strings.Repeat("a", 4097), AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: testChatToken, MaxBodyBytes: maximumChatBodyBytes + 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: testChatToken, MaxBodyBytes: 1, RunTimeout: maximumChatRunTime + time.Second},
{AppID: testDashScopeAppID, AuthToken: testChatToken, AllowedOrigins: []string{"*"}, MaxBodyBytes: 1, RunTimeout: time.Second},
}
for _, options := range tests {
if _, err := NewDashScopeChatHandler(&fakeChatUseCase{}, options); err == nil {
t.Fatalf("NewDashScopeChatHandler(%#v) error = nil", options)
}
}
if _, err := NewDashScopeChatHandler(nil, DashScopeChatOptions{
AppID: testDashScopeAppID, AuthToken: testChatToken, MaxBodyBytes: 1, RunTimeout: time.Second,
}); err == nil {
t.Fatal("NewDashScopeChatHandler(nil) error = nil")
}
}
func TestNewDashScopeChatHandlerAllowsExplicitLegacyShortToken(t *testing.T) {
_, err := NewDashScopeChatHandler(&fakeChatUseCase{}, DashScopeChatOptions{
AppID: testDashScopeAppID,
AuthToken: "delivered-key",
AllowLegacyShortToken: true,
MaxBodyBytes: 1,
RunTimeout: time.Second,
})
if err != nil {
t.Fatalf("NewDashScopeChatHandler() error = %v", err)
}
}
func TestDashScopeChatStreamsCompatibleResultAfterStrictSuccess(t *testing.T) {
turn := &fakeChatTurn{
conversationID: "conv_0123456789abcdef01234567",
events: []service.AgentTraceEvent{
{Event: "message.delta", Status: "running"},
{Event: "tool.started", ToolName: "sensitive_tool", Status: "running"},
},
result: service.ChatResult{
ConversationID: "conv_0123456789abcdef01234567",
RunID: "provider-run-must-not-leak",
ModelID: "qwen-plus-latest",
Answer: "候选水源需要现场确认。",
Usage: service.ChatTokenUsage{Input: 12, Output: 34, Total: 46},
},
}
chat := &fakeChatUseCase{turn: turn}
var logs bytes.Buffer
handler := newTestDashScopeChatHandler(t, chat, []string{"https://allowed.example"}, log.New(&logs, "", 0))
request := authenticatedDashScopeRequest(http.MethodPost, `{"input":{"prompt":"不要记录 secret prompt"},"parameters":{"incremental_output":true},"debug":{}}`)
request.Header.Set("Origin", "https://allowed.example")
request.Header.Set("X-Request-ID", "compat-request-1")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.HasPrefix(response.Header().Get("Content-Type"), "text/event-stream") {
t.Fatalf("status=%d content-type=%q body=%s", response.Code, response.Header().Get("Content-Type"), response.Body.String())
}
if response.Header().Get("X-Accel-Buffering") != "no" || response.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("stream headers = %#v", response.Header())
}
blocks := dashScopeSSEBlocks(response.Body.String())
if len(blocks) != 2 {
t.Fatalf("SSE block count=%d body=%s", len(blocks), response.Body.String())
}
for index, expectedID := range []string{"id: 1", "id: 2"} {
if !strings.Contains(blocks[index], expectedID) || !strings.Contains(blocks[index], "event: result") || !strings.Contains(blocks[index], ":HTTP_STATUS/200") {
t.Fatalf("block %d is incompatible: %s", index, blocks[index])
}
}
initial := decodeDashScopeSSEData(t, blocks[0])
if initial.Output.SessionID != turn.conversationID || initial.Output.FinishReason != "null" || initial.Output.Text != "" || len(initial.Usage.Models) != 0 || initial.RequestID != "compat-request-1" {
t.Fatalf("initial payload = %#v", initial)
}
final := decodeDashScopeSSEData(t, blocks[1])
if final.Output.SessionID != turn.conversationID || final.Output.FinishReason != "stop" || final.Output.Text != "候选水源需要现场确认。" || final.RequestID != "compat-request-1" {
t.Fatalf("final payload = %#v", final)
}
if len(final.Usage.Models) != 1 || final.Usage.Models[0].InputTokens != 12 || final.Usage.Models[0].OutputTokens != 34 || final.Usage.Models[0].ModelID != "qwen-plus-latest" {
t.Fatalf("final usage = %#v", final.Usage)
}
body := response.Body.String()
for _, forbidden := range []string{"message.delta", "sensitive_tool", "provider-run-must-not-leak"} {
if strings.Contains(body, forbidden) {
t.Fatalf("SSE exposed %q: %s", forbidden, body)
}
}
if chat.lastRequest.Message != "不要记录 secret prompt" || chat.lastRequest.ConversationID != "" || chat.lastRequest.RequestID != "compat-request-1" {
t.Fatalf("service request = %#v", chat.lastRequest)
}
if !turn.closed {
t.Fatal("turn was not closed")
}
for _, sensitive := range []string{"secret prompt", "候选水源", turn.conversationID, testChatToken} {
if strings.Contains(logs.String(), sensitive) {
t.Fatalf("logs contain sensitive value %q: %s", sensitive, logs.String())
}
}
}
func TestDashScopeChatMapsSessionIDToLocalConversation(t *testing.T) {
turn := &fakeChatTurn{
conversationID: "conv_0123456789abcdef01234567",
reused: true,
result: service.ChatResult{
ConversationID: "conv_0123456789abcdef01234567",
Answer: "ok",
},
}
chat := &fakeChatUseCase{turn: turn}
handler := newTestDashScopeChatHandler(t, chat, nil, nil)
request := authenticatedDashScopeRequest(http.MethodPost, `{"input":{"prompt":"follow up","session_id":"conv_0123456789abcdef01234567"},"parameters":{}}`)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
if chat.lastRequest.ConversationID != "conv_0123456789abcdef01234567" {
t.Fatalf("conversation ID = %q", chat.lastRequest.ConversationID)
}
}
func TestDashScopeChatTransportGuardsBeforeService(t *testing.T) {
tests := []struct {
name string
method string
body string
configure func(*http.Request)
maxBody int64
wantStatus int
wantCode string
}{
{name: "wrong app", method: http.MethodPost, body: `{"input":{"prompt":"hello"}}`, configure: func(r *http.Request) { r.SetPathValue("app_id", "another-app") }, wantStatus: http.StatusNotFound, wantCode: "CHAT_APP_NOT_FOUND"},
{name: "wrong method", method: http.MethodGet, body: `{}`, wantStatus: http.StatusMethodNotAllowed, wantCode: "CHAT_METHOD_NOT_ALLOWED"},
{name: "forbidden origin", method: http.MethodPost, body: `{"input":{"prompt":"hello"}}`, configure: func(r *http.Request) { r.Header.Set("Origin", "https://evil.example") }, wantStatus: http.StatusForbidden, wantCode: "CHAT_ORIGIN_FORBIDDEN"},
{name: "missing xtoken", method: http.MethodPost, body: `{"input":{"prompt":"hello"}}`, configure: func(r *http.Request) { r.Header.Del("xtoken") }, wantStatus: http.StatusUnauthorized, wantCode: "CHAT_AUTH_INVALID"},
{name: "bearer is not xtoken", method: http.MethodPost, body: `{"input":{"prompt":"hello"}}`, configure: func(r *http.Request) { r.Header.Del("xtoken"); r.Header.Set("Authorization", "Bearer "+testChatToken) }, wantStatus: http.StatusUnauthorized, wantCode: "CHAT_AUTH_INVALID"},
{name: "wrong content type", method: http.MethodPost, body: `{"input":{"prompt":"hello"}}`, configure: func(r *http.Request) { r.Header.Set("Content-Type", "text/plain") }, wantStatus: http.StatusUnsupportedMediaType, wantCode: "CHAT_CONTENT_TYPE_INVALID"},
{name: "body too large", method: http.MethodPost, body: `{"input":{"prompt":"hello"}}`, maxBody: 4, wantStatus: http.StatusRequestEntityTooLarge, wantCode: "CHAT_REQUEST_BODY_TOO_LARGE"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chat := &fakeChatUseCase{}
maxBody := tt.maxBody
if maxBody == 0 {
maxBody = 4096
}
handler := newTestDashScopeChatHandlerWithLimit(t, chat, maxBody, []string{"https://allowed.example"}, nil)
request := authenticatedDashScopeRequest(tt.method, tt.body)
if tt.configure != nil {
tt.configure(request)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != tt.wantStatus || !strings.Contains(response.Body.String(), `"code":"`+tt.wantCode+`"`) {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
if chat.prepareCalls != 0 {
t.Fatalf("Prepare calls = %d, want 0", chat.prepareCalls)
}
})
}
}
func TestDashScopeChatCORSPreflight(t *testing.T) {
handler := newTestDashScopeChatHandler(t, &fakeChatUseCase{}, []string{"http://localhost:5173"}, nil)
allowed := authenticatedDashScopeRequest(http.MethodOptions, "")
allowed.Header.Set("Origin", "http://localhost:5173")
allowed.Header.Set("Access-Control-Request-Method", http.MethodPost)
allowed.Header.Set("Access-Control-Request-Headers", "content-type, xtoken, x-dashscope-sse, x-request-id")
allowedResponse := httptest.NewRecorder()
handler.ServeHTTP(allowedResponse, allowed)
if allowedResponse.Code != http.StatusNoContent || allowedResponse.Header().Get("Access-Control-Allow-Origin") != "http://localhost:5173" {
t.Fatalf("allowed preflight status=%d headers=%#v body=%s", allowedResponse.Code, allowedResponse.Header(), allowedResponse.Body.String())
}
forbidden := authenticatedDashScopeRequest(http.MethodOptions, "")
forbidden.Header.Set("Origin", "http://localhost:5173")
forbidden.Header.Set("Access-Control-Request-Method", http.MethodPost)
forbidden.Header.Set("Access-Control-Request-Headers", "authorization")
forbiddenResponse := httptest.NewRecorder()
handler.ServeHTTP(forbiddenResponse, forbidden)
if forbiddenResponse.Code != http.StatusForbidden || !strings.Contains(forbiddenResponse.Body.String(), "CHAT_ORIGIN_FORBIDDEN") {
t.Fatalf("forbidden preflight status=%d body=%s", forbiddenResponse.Code, forbiddenResponse.Body.String())
}
}
func TestDashScopeChatRejectsNonCompatibleJSON(t *testing.T) {
tests := []string{
``,
`[]`,
`{}`,
`{"input":null}`,
`{"input":{}}`,
`{"input":{"prompt":null}}`,
`{"input":{"prompt":" "}}`,
`{"input":{"prompt":"hello","session_id":null}}`,
`{"input":{"prompt":"hello","user_id":"admin"}}`,
`{"input":{"prompt":"hello"},"parameters":null}`,
`{"input":{"prompt":"hello"},"parameters":{"incremental_output":null}}`,
`{"input":{"prompt":"hello"},"parameters":{"temperature":1}}`,
`{"input":{"prompt":"hello"},"parameters":{"incremental_output":"yes"}}`,
`{"input":{"prompt":"hello"},"debug":null}`,
`{"input":{"prompt":"hello"},"debug":{"trace":true}}`,
`{"input":{"prompt":"hello"},"metadata":{"role":"admin"}}`,
`{"input":{"prompt":"hello"}} {}`,
}
for _, body := range tests {
t.Run(fmt.Sprintf("%q", body), func(t *testing.T) {
chat := &fakeChatUseCase{}
handler := newTestDashScopeChatHandler(t, chat, nil, nil)
request := authenticatedDashScopeRequest(http.MethodPost, body)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), `"code":"CHAT_REQUEST_INVALID"`) {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
if chat.prepareCalls != 0 {
t.Fatalf("Prepare calls = %d, want 0", chat.prepareCalls)
}
})
}
}
func TestDashScopeChatStreamFailureDoesNotExposePartialAnswerOrStop(t *testing.T) {
turn := &fakeChatTurn{
conversationID: "conv_0123456789abcdef01234567",
events: []service.AgentTraceEvent{
{Event: "message.delta", ToolName: "secret tool", Status: "partial answer that must not leak"},
},
err: fmt.Errorf("provider secret response: %w", service.ErrChatUpstreamProtocol),
}
handler := newTestDashScopeChatHandler(t, &fakeChatUseCase{turn: turn}, nil, nil)
request := authenticatedDashScopeRequest(http.MethodPost, `{"input":{"prompt":"hello"}}`)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
body := response.Body.String()
if response.Code != http.StatusOK || !strings.Contains(body, "event: error") || !strings.Contains(body, "CHAT_UPSTREAM_PROTOCOL_ERROR") {
t.Fatalf("status=%d body=%s", response.Code, body)
}
for _, forbidden := range []string{`"finish_reason":"stop"`, "partial answer", "provider secret response", "secret tool"} {
if strings.Contains(body, forbidden) {
t.Fatalf("failure stream exposed %q: %s", forbidden, body)
}
}
}
func TestDashScopeChatPreparationBusyReturnsRetryAfter(t *testing.T) {
handler := newTestDashScopeChatHandler(t, &fakeChatUseCase{err: service.ErrChatConversationBusy}, nil, nil)
request := authenticatedDashScopeRequest(http.MethodPost, `{"input":{"prompt":"hello","session_id":"conv_0123456789abcdef01234567"}}`)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusConflict || response.Header().Get("Retry-After") != "1" || !strings.Contains(response.Body.String(), `"code":"CHAT_CONVERSATION_BUSY"`) {
t.Fatalf("status=%d headers=%#v body=%s", response.Code, response.Header(), response.Body.String())
}
}
func newTestDashScopeChatHandler(t *testing.T, chat ChatUseCase, origins []string, logger *log.Logger) *DashScopeChatHandler {
t.Helper()
return newTestDashScopeChatHandlerWithLimit(t, chat, 4096, origins, logger)
}
func newTestDashScopeChatHandlerWithLimit(t *testing.T, chat ChatUseCase, maxBody int64, origins []string, logger *log.Logger) *DashScopeChatHandler {
t.Helper()
handler, err := NewDashScopeChatHandler(chat, DashScopeChatOptions{
AppID: testDashScopeAppID,
AuthToken: testChatToken,
AllowedOrigins: origins,
MaxBodyBytes: maxBody,
RunTimeout: time.Minute,
Logger: logger,
})
if err != nil {
t.Fatalf("NewDashScopeChatHandler() error = %v", err)
}
return handler
}
func authenticatedDashScopeRequest(method, body string) *http.Request {
request := httptest.NewRequest(method, "/api/v1/apps/"+testDashScopeAppID+"/completion", strings.NewReader(body))
request.SetPathValue("app_id", testDashScopeAppID)
request.Header.Set("xtoken", testChatToken)
request.Header.Set("Content-Type", "application/json")
return request
}
func dashScopeSSEBlocks(body string) []string {
trimmed := strings.TrimSpace(body)
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "\n\n")
}
func decodeDashScopeSSEData(t *testing.T, block string) dashScopeResultPayload {
t.Helper()
for _, line := range strings.Split(block, "\n") {
if !strings.HasPrefix(line, "data: ") {
continue
}
var payload dashScopeResultPayload
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &payload); err != nil {
t.Fatalf("decode SSE data: %v", err)
}
return payload
}
t.Fatalf("SSE block has no data: %s", block)
return dashScopeResultPayload{}
}