初始化第一版
This commit is contained in:
commit
8a6c31c14d
83 files changed
+14302
No files matched your search
@@ -0,0 +1,330 @@
|
||||
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: 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 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{}
|
||||
}
|
||||
Reference in new issue
Block a user