From e286fca71bbdd6b4d35672ae01f88bafafb0d650 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 17 Sep 2026 17:03:08 +0000 Subject: [PATCH] fix(server): fail-closed chat import when Env is empty Drop Env==\"\" as a dev alias; pin compose app/events ENV alignment and main depends_on events with unit tests. Co-authored-by: Cursor --- .../config/compose_env_consistency_test.go | 110 ++++++++++++++++++ internal/server/chat_window_api.go | 4 +- internal/server/chat_window_api_test.go | 27 +++++ 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 internal/config/compose_env_consistency_test.go diff --git a/internal/config/compose_env_consistency_test.go b/internal/config/compose_env_consistency_test.go new file mode 100644 index 00000000..2189b081 --- /dev/null +++ b/internal/config/compose_env_consistency_test.go @@ -0,0 +1,110 @@ +package config_test + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +func composeRepoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} + +var composeServiceENV = regexp.MustCompile(`(?m)^\s+ENV:\s*(\S+)\s*$`) + +func extractServiceENV(composeYAML, serviceName string) (string, error) { + // Find " serviceName:" at top-level service indentation (two spaces). + marker := "\n " + serviceName + ":\n" + idx := strings.Index(composeYAML, marker) + if idx < 0 { + // file may start with the service after "services:\n" + alt := "\nservices:\n " + serviceName + ":\n" + idx = strings.Index(composeYAML, alt) + if idx < 0 { + return "", os.ErrNotExist + } + idx += len("\nservices:") + } + rest := composeYAML[idx+1:] // start at " serviceName:" + // Next top-level service starts at "\n :" where name is not indented further. + next := regexp.MustCompile(`(?m)^ [a-zA-Z0-9_-]+:\s*$`) + locs := next.FindAllStringIndex(rest, -1) + block := rest + if len(locs) >= 2 { + // locs[0] is current service header; locs[1] is next sibling + block = rest[:locs[1][0]] + } + m := composeServiceENV.FindStringSubmatch(block) + if m == nil { + return "", os.ErrNotExist + } + return strings.Trim(m[1], `"'`), nil +} + +func TestComposeAppEventsENVAligned(t *testing.T) { + root := composeRepoRoot(t) + files := []string{ + "docker-compose.dev.yml", + "docker-compose.test.yml", + "docker-compose.main.yml", + } + for _, name := range files { + raw, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + text := string(raw) + appEnv, err := extractServiceENV(text, "app") + if err != nil { + t.Fatalf("%s: app ENV: %v", name, err) + } + eventsEnv, err := extractServiceENV(text, "events") + if err != nil { + t.Fatalf("%s: events ENV: %v", name, err) + } + if appEnv != eventsEnv { + t.Fatalf("%s: app ENV=%q != events ENV=%q", name, appEnv, eventsEnv) + } + } +} + +func TestComposeMainAppDependsOnEventsHealthy(t *testing.T) { + root := composeRepoRoot(t) + raw, err := os.ReadFile(filepath.Join(root, "docker-compose.main.yml")) + if err != nil { + t.Fatal(err) + } + text := string(raw) + marker := "\n app:\n" + idx := strings.Index(text, marker) + if idx < 0 { + t.Fatal("app service not found in docker-compose.main.yml") + } + rest := text[idx+1:] + next := regexp.MustCompile(`(?m)^ [a-zA-Z0-9_-]+:\s*$`) + locs := next.FindAllStringIndex(rest, -1) + block := rest + if len(locs) >= 2 { + block = rest[:locs[1][0]] + } + // Nail E-2: app depends_on events with service_healthy + re := regexp.MustCompile(`(?m)^\s+events:\s*\n\s+condition:\s*service_healthy\s*$`) + if !re.MatchString(block) { + t.Fatalf("docker-compose.main.yml app must depends_on events with condition: service_healthy\n--- app block ---\n%s", block) + } + appEnv, err := extractServiceENV(text, "app") + if err != nil { + t.Fatal(err) + } + if appEnv != "main" { + t.Fatalf("main app ENV want main, got %q", appEnv) + } +} diff --git a/internal/server/chat_window_api.go b/internal/server/chat_window_api.go index e2c67d25..f279a7da 100644 --- a/internal/server/chat_window_api.go +++ b/internal/server/chat_window_api.go @@ -167,8 +167,8 @@ func (s *Server) handleImportChatMessages(w http.ResponseWriter, r *http.Request return } - // 安全门禁:生产环境默认禁止调用,仅在 dev 或显式开启时开放 - isDev := s.cfg.Env == "dev" || s.cfg.Env == "development" || s.cfg.Env == "" || os.Getenv("ENABLE_DEV_IMPORT") == "true" + // 安全门禁:生产环境默认禁止调用,仅在显式 dev 或 ENABLE_DEV_IMPORT 时开放 + isDev := s.cfg.Env == "dev" || s.cfg.Env == "development" || os.Getenv("ENABLE_DEV_IMPORT") == "true" if !isDev { writeSafeError(w, r, http.StatusForbidden, "chat messages import endpoint is only available in dev/demo environment") return diff --git a/internal/server/chat_window_api_test.go b/internal/server/chat_window_api_test.go index 42316542..954695f0 100644 --- a/internal/server/chat_window_api_test.go +++ b/internal/server/chat_window_api_test.go @@ -179,3 +179,30 @@ func TestServer_ChatWindow_ImportGuard(t *testing.T) { t.Fatalf("expected 403 Forbidden in production env, got %d", rrImport.Code) } } + +func TestServer_ChatWindow_ImportGuard_EmptyEnvFailsClosed(t *testing.T) { + // E-5:Env 为空不得视同 dev,导入端点默认拒绝 + cfg := &config.Config{ + Env: "test", + HTTPHost: "127.0.0.1", + HTTPPort: 8889, + DataDir: t.TempDir(), + AllowInMemory: true, + AgentBackend: "stub", + } + svc := bizdev.MustNewService(t, cfg) + srv := server.NewServer(cfg, svc) + cfg.Env = "" + t.Setenv("ENABLE_DEV_IMPORT", "") + handler := srv.Routes() + + reqImport := httptest.NewRequest("POST", "/api/v1/chat/messages/import", bytes.NewReader([]byte("[]"))) + reqImport.Header.Set("Content-Type", "application/json") + withBearerAuth(reqImport, loginTestAdmin(t, handler, cfg)) + rrImport := httptest.NewRecorder() + handler.ServeHTTP(rrImport, reqImport) + + if rrImport.Code != http.StatusForbidden { + t.Fatalf("expected 403 Forbidden when Env is empty, got %d body=%s", rrImport.Code, rrImport.Body.String()) + } +}