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("spa"), 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") } }