9501751792
三轮审查修复(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 币种维度)
168 lines
7.7 KiB
Go
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
|
|
}
|