2026-05-23 22:10:20 +08:00
|
|
|
package bootstrap
|
|
|
|
|
|
|
|
|
|
import (
|
2026-05-27 22:18:43 +08:00
|
|
|
"encoding/json"
|
2026-05-23 22:10:20 +08:00
|
|
|
"net/http"
|
2026-05-23 22:44:06 +08:00
|
|
|
"net/http/httptest"
|
|
|
|
|
"strings"
|
2026-05-23 22:10:20 +08:00
|
|
|
"testing"
|
|
|
|
|
|
2026-05-31 18:43:48 +08:00
|
|
|
"agent-desk/internal/pkg/config"
|
2026-05-23 22:10:20 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestNewServerRegistersGinRoutes(t *testing.T) {
|
|
|
|
|
config.SetCurrent(&config.Config{
|
|
|
|
|
Storage: config.StorageConfig{
|
|
|
|
|
Local: config.LocalStorageConfig{
|
|
|
|
|
Root: "storage",
|
|
|
|
|
BaseURL: "/storage",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
app, err := NewServer()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
routes := make(map[string]bool)
|
|
|
|
|
for _, route := range app.Routes() {
|
|
|
|
|
routes[route.Method+" "+route.Path] = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expected := []string{
|
|
|
|
|
http.MethodPost + " /api/auth/login",
|
2026-06-26 14:46:01 +08:00
|
|
|
http.MethodGet + " /api/config",
|
2026-06-13 09:59:21 +08:00
|
|
|
http.MethodGet + " /api/health",
|
2026-05-24 20:49:10 +08:00
|
|
|
http.MethodGet + " /api/auth/oidc_login",
|
|
|
|
|
http.MethodGet + " /api/auth/oidc_callback",
|
|
|
|
|
http.MethodPost + " /api/auth/oidc_exchange",
|
2026-05-23 22:10:20 +08:00
|
|
|
http.MethodGet + " /api/auth/profile",
|
|
|
|
|
http.MethodGet + " /api/dashboard/user/list",
|
|
|
|
|
http.MethodGet + " /api/dashboard/user/:id",
|
|
|
|
|
http.MethodPost + " /api/dashboard/user/create",
|
|
|
|
|
http.MethodPost + " /api/dashboard/conversation/send_message",
|
2026-06-25 18:50:38 +08:00
|
|
|
http.MethodGet + " /api/dashboard/ai-workflow/default-definition",
|
2026-07-25 12:04:06 +08:00
|
|
|
http.MethodGet + " /api/dashboard/ai-workflow/template/list",
|
2026-06-23 23:05:17 +08:00
|
|
|
http.MethodGet + " /api/dashboard/ai-workflow/run/list",
|
|
|
|
|
http.MethodGet + " /api/dashboard/ai-workflow/run/:id",
|
2026-07-25 12:04:06 +08:00
|
|
|
http.MethodGet + " /api/dashboard/agent-run/metrics",
|
|
|
|
|
http.MethodPost + " /api/dashboard/agent-run/evaluate",
|
|
|
|
|
http.MethodGet + " /api/dashboard/agent-run/:id",
|
|
|
|
|
http.MethodPost + " /api/dashboard/ai-agent/rollback_rollout",
|
|
|
|
|
http.MethodPost + " /api/dashboard/channel/rollback_ai_agent_rollout",
|
|
|
|
|
http.MethodPost + " /api/dashboard/agent-run/quality_feedback",
|
|
|
|
|
http.MethodGet + " /api/dashboard/agent-run/list",
|
2026-05-23 22:10:20 +08:00
|
|
|
http.MethodGet + " /api/ws/dashboard",
|
|
|
|
|
http.MethodGet + " /api/ws/open",
|
|
|
|
|
}
|
|
|
|
|
for _, route := range expected {
|
|
|
|
|
if !routes[route] {
|
|
|
|
|
t.Fatalf("expected route %s to be registered", route)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-23 22:44:06 +08:00
|
|
|
|
2026-06-13 09:59:21 +08:00
|
|
|
func TestNewServerHealthEndpointIsPublic(t *testing.T) {
|
|
|
|
|
config.SetCurrent(&config.Config{
|
|
|
|
|
Storage: config.StorageConfig{
|
|
|
|
|
Local: config.LocalStorageConfig{
|
|
|
|
|
Root: "storage",
|
|
|
|
|
BaseURL: "/storage",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
app, err := NewServer()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/health", nil))
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status=%d want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var body struct {
|
|
|
|
|
Success bool `json:"success"`
|
|
|
|
|
Data struct {
|
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
} `json:"data"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !body.Success {
|
|
|
|
|
t.Fatalf("success=false, body=%s", rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if body.Data.Status != "ok" {
|
|
|
|
|
t.Fatalf("status=%q want ok", body.Data.Status)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 14:46:01 +08:00
|
|
|
func TestNewServerExposesPublicConfig(t *testing.T) {
|
2026-05-27 22:18:43 +08:00
|
|
|
config.SetCurrent(&config.Config{
|
2026-06-26 14:46:01 +08:00
|
|
|
Language: "zh-CN",
|
2026-05-27 22:18:43 +08:00
|
|
|
Storage: config.StorageConfig{
|
|
|
|
|
Local: config.LocalStorageConfig{
|
|
|
|
|
Root: "storage",
|
|
|
|
|
BaseURL: "/storage",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
WxWork: config.WxWorkConfig{
|
|
|
|
|
Enabled: true,
|
|
|
|
|
},
|
|
|
|
|
OIDC: config.OIDCConfig{
|
|
|
|
|
Enabled: false,
|
|
|
|
|
ClientSecret: "must-not-leak",
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
app, err := NewServer()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
2026-06-26 14:46:01 +08:00
|
|
|
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/config", nil))
|
2026-05-27 22:18:43 +08:00
|
|
|
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status=%d want %d", rec.Code, http.StatusOK)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var body struct {
|
|
|
|
|
Success bool `json:"success"`
|
|
|
|
|
Data struct {
|
2026-06-26 14:46:01 +08:00
|
|
|
Language string `json:"language"`
|
|
|
|
|
WxWorkEnabled bool `json:"wxworkEnabled"`
|
|
|
|
|
OIDCEnabled bool `json:"oidcEnabled"`
|
2026-05-27 22:18:43 +08:00
|
|
|
} `json:"data"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !body.Success {
|
|
|
|
|
t.Fatalf("success=false, body=%s", rec.Body.String())
|
|
|
|
|
}
|
2026-06-26 14:46:01 +08:00
|
|
|
if body.Data.Language != "zh-CN" {
|
|
|
|
|
t.Fatalf("language=%q want zh-CN", body.Data.Language)
|
|
|
|
|
}
|
2026-05-27 22:18:43 +08:00
|
|
|
if !body.Data.WxWorkEnabled {
|
|
|
|
|
t.Fatalf("wxworkEnabled=false want true")
|
|
|
|
|
}
|
|
|
|
|
if body.Data.OIDCEnabled {
|
|
|
|
|
t.Fatalf("oidcEnabled=true want false")
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(rec.Body.String(), "must-not-leak") {
|
|
|
|
|
t.Fatalf("response leaked sensitive OIDC config: %s", rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 14:46:01 +08:00
|
|
|
func TestNewServerDoesNotExposeLegacyAuthOptions(t *testing.T) {
|
|
|
|
|
config.SetCurrent(&config.Config{
|
|
|
|
|
Storage: config.StorageConfig{
|
|
|
|
|
Local: config.LocalStorageConfig{
|
|
|
|
|
Root: "storage",
|
|
|
|
|
BaseURL: "/storage",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
app, err := NewServer()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/options", nil))
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
|
|
|
t.Fatalf("status=%d want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 21:46:55 +08:00
|
|
|
func TestNewServerReturnsJSONNotFoundWithoutFrontendSPA(t *testing.T) {
|
2026-05-23 22:44:06 +08:00
|
|
|
config.SetCurrent(&config.Config{
|
|
|
|
|
Storage: config.StorageConfig{
|
|
|
|
|
Local: config.LocalStorageConfig{
|
|
|
|
|
Root: "storage",
|
|
|
|
|
BaseURL: "/storage",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
app, err := NewServer()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
path string
|
|
|
|
|
wantStatus int
|
|
|
|
|
contentType string
|
|
|
|
|
}{
|
|
|
|
|
{path: "/api/not-exists", wantStatus: http.StatusNotFound, contentType: "application/json"},
|
2026-08-20 21:46:55 +08:00
|
|
|
{path: "/dashboard/not-exists", wantStatus: http.StatusNotFound, contentType: "application/json"},
|
2026-05-23 22:44:06 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
|
|
|
|
|
|
|
|
|
if rec.Code != tt.wantStatus {
|
|
|
|
|
t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(rec.Header().Get("Content-Type"), tt.contentType) {
|
|
|
|
|
t.Fatalf("%s Content-Type=%q want %q", tt.path, rec.Header().Get("Content-Type"), tt.contentType)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-27 22:05:02 +08:00
|
|
|
|
|
|
|
|
func TestNewServerAllowsConfiguredCORSOrigin(t *testing.T) {
|
|
|
|
|
config.SetCurrent(&config.Config{
|
|
|
|
|
Server: config.ServerConfig{
|
|
|
|
|
CORS: config.CORSConfig{
|
|
|
|
|
AllowedOrigins: []string{"https://console.example.com"},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
Storage: config.StorageConfig{
|
|
|
|
|
Local: config.LocalStorageConfig{
|
|
|
|
|
Root: "storage",
|
|
|
|
|
BaseURL: "/storage",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
app, err := NewServer()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
|
|
|
|
req.Header.Set("Origin", "https://console.example.com")
|
|
|
|
|
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
|
|
|
|
app.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusNoContent {
|
|
|
|
|
t.Fatalf("status=%d want %d", rec.Code, http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://console.example.com" {
|
|
|
|
|
t.Fatalf("Access-Control-Allow-Origin=%q want %q", got, "https://console.example.com")
|
|
|
|
|
}
|
|
|
|
|
if got := rec.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(got, http.MethodPost) {
|
|
|
|
|
t.Fatalf("Access-Control-Allow-Methods=%q should contain %q", got, http.MethodPost)
|
|
|
|
|
}
|
|
|
|
|
if got := rec.Header().Get("Vary"); got != "Origin" {
|
|
|
|
|
t.Fatalf("Vary=%q want %q", got, "Origin")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestNewServerRejectsUnconfiguredCORSOrigin(t *testing.T) {
|
|
|
|
|
config.SetCurrent(&config.Config{
|
|
|
|
|
Server: config.ServerConfig{
|
|
|
|
|
CORS: config.CORSConfig{
|
|
|
|
|
AllowedOrigins: []string{"https://console.example.com"},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
Storage: config.StorageConfig{
|
|
|
|
|
Local: config.LocalStorageConfig{
|
|
|
|
|
Root: "storage",
|
|
|
|
|
BaseURL: "/storage",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
app, err := NewServer()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
|
|
|
|
req.Header.Set("Origin", "https://evil.example.com")
|
|
|
|
|
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
|
|
|
|
app.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
|
|
|
t.Fatalf("status=%d want %d", rec.Code, http.StatusForbidden)
|
|
|
|
|
}
|
|
|
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
|
|
|
t.Fatalf("Access-Control-Allow-Origin=%q want empty", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-27 22:18:43 +08:00
|
|
|
|
|
|
|
|
func TestNewServerEchoesRequestID(t *testing.T) {
|
|
|
|
|
config.SetCurrent(&config.Config{
|
|
|
|
|
Storage: config.StorageConfig{
|
|
|
|
|
Local: config.LocalStorageConfig{
|
|
|
|
|
Root: "storage",
|
|
|
|
|
BaseURL: "/storage",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
app, err := NewServer()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/not-exists", nil)
|
|
|
|
|
req.Header.Set("X-Request-Id", "trace-123")
|
|
|
|
|
app.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if got := rec.Header().Get("X-Request-Id"); got != "trace-123" {
|
|
|
|
|
t.Fatalf("X-Request-Id=%q want %q", got, "trace-123")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestNewServerGeneratesRequestID(t *testing.T) {
|
|
|
|
|
config.SetCurrent(&config.Config{
|
|
|
|
|
Storage: config.StorageConfig{
|
|
|
|
|
Local: config.LocalStorageConfig{
|
|
|
|
|
Root: "storage",
|
|
|
|
|
BaseURL: "/storage",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
app, err := NewServer()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/not-exists", nil))
|
|
|
|
|
|
|
|
|
|
if got := rec.Header().Get("X-Request-Id"); got == "" {
|
|
|
|
|
t.Fatalf("X-Request-Id should be generated")
|
|
|
|
|
}
|
|
|
|
|
}
|