Files
agent-desk-ng/internal/bootstrap/server_route_test.go

480 lines
14 KiB
Go

package bootstrap
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"agent-desk/internal/pkg/config"
)
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",
http.MethodGet + " /api/config",
http.MethodGet + " /api/health",
http.MethodGet + " /api/auth/oidc_login",
http.MethodGet + " /api/auth/oidc_callback",
http.MethodGet + " /api/auth/callback/custom",
http.MethodPost + " /api/auth/oidc_exchange",
http.MethodGet + " /api/auth/profile",
http.MethodPost + " /api/webhooks/org-sync",
http.MethodGet + " /api/dashboard/organization/my_list",
http.MethodPost + " /api/dashboard/organization/switch",
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",
http.MethodGet + " /api/conversation/:id",
http.MethodGet + " /api/conversation/list",
http.MethodPost + " /api/conversation/create",
http.MethodPost + " /api/conversation/create_or_match",
http.MethodPost + " /api/conversation/close",
http.MethodPost + " /api/third/email/webhook",
http.MethodPost + " /api/third/email/webhook/:channel_id",
http.MethodPost + " /api/third/email/request",
http.MethodPost + " /api/third/email/request/:channel_id",
http.MethodGet + " /api/dashboard/ai-workflow/default-definition",
http.MethodGet + " /api/dashboard/ai-workflow/template/list",
http.MethodGet + " /api/dashboard/ai-workflow/run/list",
http.MethodGet + " /api/dashboard/ai-workflow/run/:id",
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",
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)
}
}
}
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)
}
}
func TestNewServerExposesPublicConfig(t *testing.T) {
config.SetCurrent(&config.Config{
Language: "zh-CN",
Storage: config.StorageConfig{
Local: config.LocalStorageConfig{
Root: "storage",
BaseURL: "/storage",
},
},
OIDC: config.OIDCConfig{
Enabled: false,
ClientSecret: "must-not-leak",
},
})
app, err := NewServer()
if err != nil {
t.Fatalf("NewServer() error = %v", err)
}
rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/config", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d want %d", rec.Code, http.StatusOK)
}
var body struct {
Success bool `json:"success"`
Data struct {
Language string `json:"language"`
PasswordLoginEnabled bool `json:"passwordLoginEnabled"`
OIDCEnabled bool `json:"oidcEnabled"`
} `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.Language != "zh-CN" {
t.Fatalf("language=%q want zh-CN", body.Data.Language)
}
if !body.Data.PasswordLoginEnabled {
t.Fatalf("passwordLoginEnabled=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())
}
}
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())
}
}
func TestNewServerPasswordLoginDisabled(t *testing.T) {
disabled := false
config.SetCurrent(&config.Config{
Auth: config.AuthConfig{
PasswordLoginEnabled: &disabled,
},
Storage: config.StorageConfig{
Local: config.LocalStorageConfig{
Root: "storage",
BaseURL: "/storage",
},
},
})
app, err := NewServer()
if err != nil {
t.Fatalf("NewServer() error = %v", err)
}
// 1. /api/config should return passwordLoginEnabled=false
rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/config", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d want %d", rec.Code, http.StatusOK)
}
var configBody struct {
Data struct {
PasswordLoginEnabled bool `json:"passwordLoginEnabled"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &configBody); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if configBody.Data.PasswordLoginEnabled {
t.Fatalf("expected passwordLoginEnabled=false, got true")
}
// 2. /api/auth/login should be rejected
rec = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"admin","password":"secret"}`))
req.Header.Set("Content-Type", "application/json")
app.ServeHTTP(rec, req)
var loginBody struct {
Success bool `json:"success"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &loginBody); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if loginBody.Success {
t.Fatalf("expected login failure when password login is disabled")
}
}
func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) {
for _, rootDir := range []string{"web/out", "../web/out", "../../web/out"} {
_ = os.MkdirAll(rootDir, 0o755)
_ = os.WriteFile(filepath.Join(rootDir, "index.html"), []byte("<html>spa</html>"), 0o644)
defer func(d string) {
_ = os.Remove(filepath.Join(d, "index.html"))
}(rootDir)
}
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"},
{path: "/dashboard/not-exists", wantStatus: http.StatusOK, contentType: "text/html"},
{path: "/support/docs/runtime-authored-slug", wantStatus: http.StatusOK, contentType: "text/html"},
{path: "/support/community/posts/12345", wantStatus: http.StatusOK, contentType: "text/html"},
{path: "/support/community/categories/product", wantStatus: http.StatusOK, contentType: "text/html"},
{path: "/support/community/posts/new", wantStatus: http.StatusOK, contentType: "text/html"},
}
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)
}
}
}
func TestNewServerHardensStoredAssetResponses(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"screenshot.png", "archive.zip", "legacy-page.html"} {
if err := os.WriteFile(filepath.Join(root, name), []byte("payload"), 0o644); err != nil {
t.Fatalf("WriteFile(%s) error = %v", name, err)
}
}
config.SetCurrent(&config.Config{
Storage: config.StorageConfig{
Local: config.LocalStorageConfig{
Root: root,
BaseURL: "/storage",
},
},
})
app, err := NewServer()
if err != nil {
t.Fatalf("NewServer() error = %v", err)
}
tests := []struct {
path string
wantStatus int
contentType string
wantAttachment bool
}{
{path: "/storage/screenshot.png", wantStatus: http.StatusOK, contentType: "image/png"},
{path: "/storage/archive.zip", wantStatus: http.StatusOK, wantAttachment: true},
// A file planted before the upload policy existed must still not render.
{path: "/storage/legacy-page.html", wantStatus: http.StatusOK, wantAttachment: true},
{path: "/storage/missing.png", wantStatus: http.StatusNotFound},
}
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 got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("%s X-Content-Type-Options=%q want nosniff", tt.path, got)
}
if tt.contentType != "" && !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)
}
got := rec.Header().Get("Content-Disposition")
if tt.wantAttachment && got != "attachment" {
t.Fatalf("%s Content-Disposition=%q want attachment", tt.path, got)
}
if !tt.wantAttachment && got != "" {
t.Fatalf("%s Content-Disposition=%q want empty so the asset renders inline", tt.path, got)
}
}
}
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)
}
}
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")
}
}