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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-17 17:03:08 +00:00
co-authored by Cursor
parent db964da21a
commit e286fca71b
3 changed files with 139 additions and 2 deletions
@@ -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 <name>:" 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)
}
}
+2 -2
View File
@@ -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
+27
View File
@@ -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())
}
}