Files
ai-gateway-go/internal/workbench/workbench_integration_test.go
T
superidou 9501751792 0.10.1: 安全与业务逻辑加固、新品牌与部署加固
三轮审查修复(60+ 项),相对远端 main(b536672)的关键变更:
- 安全: 数据面 SSRF 拨号防护(防 DNS rebinding)/上游凭据剥离/登录防枚举
  与锁定态统一/可信代理(X-Forwarded-For)限流加固/会话版本失效机制/
  撤销即时传播/弱密钥拒绝启动/脱敏字节级重写(保签名契约)
- 业务逻辑: 裸 body 上传 panic/bootstrap 审计管线卡死/定价通配符优先级/
  全局工具可见性/调度器停机补跑/TOTP 挑战令牌消费顺序/熔断探针语义/
  >4MB 响应 token 计量/管理员重置密码作废会话 等
- 前端: 新 logo(语枢 AI 网关主题)/Provider 凭据异常警示/删除入口/
  后端错误消息透传/localStorage 敏感数据收敛
- 部署: CREDENTIAL_MASTER_KEY 持久化与弱值拒绝/Provider DELETE 接口/
  nginx 安全头/worker 内存限制
- 新增迁移 000029(key_hash 索引)/000030(usage_daily 币种维度)
2026-08-13 10:50:51 +08:00

168 lines
7.7 KiB
Go

package workbench
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"aigateway.local/core/internal/apikey"
"aigateway.local/core/internal/gateway"
"aigateway.local/core/internal/platform/config"
"aigateway.local/core/internal/platform/cryptox"
"aigateway.local/core/internal/platform/database"
tracepkg "aigateway.local/core/internal/trace"
)
func TestWorkbenchPostgreSQLLifecycle(t *testing.T) {
databaseURL := os.Getenv("WORKBENCH_TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("WORKBENCH_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8, MinConns: 0})
if err != nil {
t.Fatal(err)
}
defer pool.Close()
actorID := "11111111-1111-4111-8111-111111111111"
_, err = pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role) VALUES($1,'m4-test','test','superadmin') ON CONFLICT(id) DO NOTHING`, actorID)
if err != nil {
t.Fatal(err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM gateway.agent_traces WHERE request_id='m4-runtime'; DELETE FROM gateway.notification_channels WHERE name='m4-webhook'; DELETE FROM gateway.applications WHERE code='m4_app'; DELETE FROM gateway.tool_definitions WHERE code='m4_lookup'; DELETE FROM gateway.knowledge_bases WHERE name='m4-integration-kb'; DELETE FROM gateway.prompt_templates WHERE name='m4-integration-prompt'`)
}
cleanup()
defer cleanup()
assets := NewService(pool)
prompt, err := assets.CreatePrompt(ctx, PromptInput{Name: "m4-integration-prompt", Description: "test", Enabled: true, Content: "请回答 {{question}}", Variables: []Variable{{Name: "question", Required: true}}}, actorID)
if err != nil {
t.Fatal(err)
}
if prompt.Current == nil || prompt.Current.Version != 1 {
t.Fatalf("unexpected prompt: %#v", prompt)
}
version, err := assets.AddPromptVersion(ctx, prompt.ID, "新版 {{question}}", []Variable{{Name: "question", Required: true}}, "v2", actorID, true)
if err != nil || version.Version != 2 {
t.Fatalf("version=%#v err=%v", version, err)
}
_, rendered, err := assets.RenderPromptByName(ctx, prompt.Name, map[string]any{"question": "可扩展吗"})
if err != nil || rendered != "新版 可扩展吗" {
t.Fatalf("render=%q err=%v", rendered, err)
}
kb, err := assets.SaveKnowledgeBase(ctx, KnowledgeBase{Name: "m4-integration-kb", Description: "test", RetrievalMode: "postgres_fts", ChunkSize: 200, ChunkOverlap: 20, Enabled: true}, actorID, true)
if err != nil {
t.Fatal(err)
}
doc, err := assets.AddKnowledgeDocument(ctx, kb.ID, "Go 架构说明", "text", "", "Go 网关采用不可变运行时快照。\n\nPostgreSQL 是权威配置存储。", actorID)
if err != nil {
t.Fatal(err)
}
if doc.ChunkCount == 0 {
t.Fatal("expected chunks")
}
hits, err := NewRetriever(assets, nil).Search(ctx, kb.ID, "不可变运行时快照", 4)
if err != nil || len(hits) == 0 {
t.Fatalf("hits=%#v err=%v", hits, err)
}
key := base64.StdEncoding.EncodeToString(make([]byte, 32))
cipher, err := cryptox.NewKeyring(key, 1, "", "m4-test-tools")
if err != nil {
t.Fatal(err)
}
receivedAuth := ""
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuth = r.Header.Get("Authorization")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}))
defer upstream.Close()
tools := NewToolService(assets, cipher, true)
tool, err := tools.Save(ctx, "", ToolInput{Code: "m4_lookup", Name: "查询", EndpointURL: upstream.URL, HTTPMethod: "POST", Headers: map[string]string{"Authorization": "Bearer secret"}, InputSchema: json.RawMessage(`{"type":"object","required":["q"],"properties":{"q":{"type":"string"}}}`), TimeoutSeconds: 5, Enabled: true}, actorID, true)
if err != nil {
t.Fatal(err)
}
result, err := tools.Execute(ctx, tool, map[string]any{"q": "gateway"}, "", "m4-test")
if err != nil {
t.Fatal(err)
}
if result["status_code"] != http.StatusOK || receivedAuth != "Bearer secret" {
t.Fatalf("result=%#v auth=%q", result, receivedAuth)
}
app, err := assets.SaveApplication(ctx, Application{Code: "m4_app", Name: "M4 App", Status: "draft", DraftConfig: ApplicationConfig{Model: "test-model", PromptTemplateID: prompt.ID, KnowledgeBaseIDs: []string{kb.ID}, ToolIDs: []string{tool.ID}, RetrievalTopK: 4, Temperature: .2, MaxToolRounds: 2}}, actorID, true)
if err != nil {
t.Fatal(err)
}
published, err := assets.PublishApplication(ctx, app.ID, "first", actorID)
if err != nil {
t.Fatal(err)
}
if published.PublishedVersion == nil || *published.PublishedVersion != 1 || published.Status != "active" {
t.Fatalf("unexpected published app: %#v", published)
}
governed := false
fakeGateway := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Error(err)
}
messages, _ := payload["messages"].([]any)
if len(messages) > 0 && strings.Contains(toString(messages[0]), "不可变运行时快照") {
governed = true
}
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "完成"}}}})
})
runtime := NewRuntimeHTTPHandler(assets, tools, NewRetriever(assets, nil), staticPrincipalAuthenticator{}, fakeGateway, MarketplaceDeps{})
traceStore := tracepkg.NewStore(pool)
runtime.SetTraceStore(traceStore)
runtimeRequest := httptest.NewRequest(http.MethodPost, "/v1/applications/m4_app/chat/completions", bytes.NewBufferString(`{"messages":[{"role":"user","content":"不可变运行时快照是什么?"}],"variables":{"question":"架构"}}`))
runtimeRequest.Header.Set("Authorization", "Bearer test")
runtimeRequest = runtimeRequest.WithContext(gateway.WithRequestID(runtimeRequest.Context(), "m4-runtime"))
runtimeResponse := httptest.NewRecorder()
runtime.ServeHTTP(runtimeResponse, runtimeRequest)
if runtimeResponse.Code != http.StatusOK || !governed || !strings.Contains(runtimeResponse.Body.String(), `"application"`) {
t.Fatalf("runtime status=%d governed=%v body=%s", runtimeResponse.Code, governed, runtimeResponse.Body.String())
}
traces, err := traceStore.List(ctx, tracepkg.Filter{From: time.Now().Add(-time.Minute), To: time.Now().Add(time.Minute), RequestID: "m4-runtime", Limit: 10})
if err != nil || len(traces) != 1 || traces[0].TraceType != "application" || traces[0].ModelCallCount != 1 {
t.Fatalf("runtime trace=%+v err=%v", traces, err)
}
detail, err := traceStore.Get(ctx, traces[0].ID)
if err != nil || len(detail.Spans) < 2 {
t.Fatalf("runtime trace detail=%+v err=%v", detail, err)
}
signed := ""
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
signed = r.Header.Get("X-Gateway-Signature")
w.WriteHeader(http.StatusNoContent)
}))
defer webhook.Close()
notifications := NewNotificationService(assets, cipher, true)
secret := "m4-signing-secret"
channel, err := notifications.SaveChannel(ctx, "", NotificationInput{Name: "m4-webhook", WebhookURL: webhook.URL, SigningSecret: &secret, EventPatterns: []string{"application.*"}, Enabled: true}, actorID, true)
if err != nil {
t.Fatal(err)
}
eventID, _ := newUUID()
delivery, err := notifications.ensureDelivery(ctx, channel, eventID, "application.published", json.RawMessage(`{"version":1}`))
if err != nil {
t.Fatal(err)
}
if err = notifications.deliver(ctx, channel, delivery); err != nil || !strings.HasPrefix(signed, "sha256=") {
t.Fatalf("delivery err=%v signature=%q", err, signed)
}
}
type staticPrincipalAuthenticator struct{}
func (staticPrincipalAuthenticator) AuthenticatePrincipal(context.Context, string) (apikey.Principal, error) {
return apikey.Principal{}, nil
}