package handler /* 回填端点的判据(2026-10-04)。 ★ 这批判据盯的是**接口化本身**(用户 2026-10-04:「一切数据调用都要接口化」): 1. 回填**不在** Migrate 里 —— 那是结构变更通道,让它改数据(会话拓扑) 会导致不可重跑、不可通过接口触发、看不出谁改的。 2. 端点挂在 AdminOnly 组 —— 会话拓扑是全站数据,非 admin 不该能改。 3. **默认 dry-run** —— 免得误点一次就改了 6 条真实会话的父子关系。 4. `PreviewSessionParents` 只读 —— 验证也必须走接口(不直连库), 所以需要一个「不写也能看到将发生什么」的接口。 */ import ( "context" "database/sql" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "strings" "testing" "github.com/agentmail/gateway/internal/db" "github.com/agentmail/gateway/internal/repo" "github.com/google/uuid" ) func readMain(t *testing.T) string { t.Helper() b, err := os.ReadFile("../../cmd/server/main.go") if err != nil { t.Fatalf("读 main.go: %v", err) } return string(b) } // ★ 回填不得留在 Migrate 里。 func TestSessionBackfillNotInMigrate(t *testing.T) { b, err := os.ReadFile("../db/migrate.go") if err != nil { t.Fatalf("读 migrate.go: %v", err) } src := string(b) for _, forbidden := range []string{"parent_session_id = (", "UPDATE sessions"} { if strings.Contains(src, forbidden) { t.Errorf("★ migrate.go 里出现了 %q —— 回填是**数据**改写,不该混进结构迁移通道。"+ "\n 后果:不可重跑、不可经接口触发、看不出谁在什么时候改的。", forbidden) } } } // ★ 两个 admin 端点都要挂在 AdminOnly 组里。 func TestSessionTreeAdminRoutesGuarded(t *testing.T) { src := readMain(t) adminGroupStart := strings.Index(src, "middleware.AdminOnly") if adminGroupStart < 0 { t.Fatal("找不到 AdminOnly 分组") } // 取 AdminOnly 分组之后的一段(到下一个 Group 或文件尾) rest := src[adminGroupStart:] if end := strings.Index(rest[1:], "r.Group("); end > 0 { rest = rest[:end] } for _, route := range []string{ "/admin/sessions/tree/backfill", "/admin/sessions/tree/preview", } { if !strings.Contains(rest, route) { t.Errorf("★ %s 不在 AdminOnly 分组里 —— 会话拓扑是全站数据,非 admin 不该能改/能看", route) } } } // ★ 默认必须 dry-run —— 测**行为**,不测源码形状。 // // 判据演进(2026-10-04):初版读源码、数 `AdminPreviewSessionParents` // 出现几次。变异 `dry := false`(正是初版那个真 bug)之后,判据**依然全绿** // —— 因为 `if dry {...}` 分支在 false 时也存在于源码里。 // ⇒ 形状判据第四次被形状骗。改成真起 httptest、直接发一个不带参数的 POST, // // 然后去**库里**看 parent 有没有被写进去。 func TestBackfillDefaultsToDryRun(t *testing.T) { setupTreeAdminHandlerDB(t) ctx := context.Background() parent := mustTreeSession(t, "父") child := mustTreeSession(t, "子") pm := mustTreeMail(t, parent, "父里的原邮件", "") mustTreeMail(t, child, "子里的回信", pm) // 不带任何参数 —— 这正是「误点一次」的场景 rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/sessions/tree/backfill", nil) AdminBackfillSessionParents(rec, req) if rec.Code != http.StatusOK { t.Fatalf("应 200,实际 %d:%s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), `"dry_run":true`) { t.Errorf("★ 无参 POST 的响应应表明这是预览,实际:%s", rec.Body.String()) } // 真正的判据在库里:不该被写进去 var got sql.NullString if err := db.DB.QueryRowContext(ctx, `SELECT parent_session_id FROM sessions WHERE session_id = ?`, child).Scan(&got); err != nil { t.Fatalf("读父: %v", err) } if got.Valid { t.Errorf("★ 无参 POST 就把 parent_session_id 写成了 %q —— 一次误点改了真实会话拓扑", got.String) } // 显式 dry_run=0 才写 rec2 := httptest.NewRecorder() req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/sessions/tree/backfill?dry_run=0", nil) AdminBackfillSessionParents(rec2, req2) if rec2.Code != http.StatusOK { t.Fatalf("显式写入应 200,实际 %d:%s", rec2.Code, rec2.Body.String()) } if err := db.DB.QueryRowContext(ctx, `SELECT parent_session_id FROM sessions WHERE session_id = ?`, child).Scan(&got); err != nil { t.Fatalf("读父: %v", err) } if got.String != parent { t.Errorf("dry_run=0 后应写入 %s,实际 %q", parent, got.String) } // 预览端点在**有真实分叉待写**时也只读 child2 := mustTreeSession(t, "子2") mustTreeMail(t, child2, "又一条回信", pm) rec3 := httptest.NewRecorder() req3 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/sessions/tree/preview", nil) AdminPreviewSessionParents(rec3, req3) if rec3.Code != http.StatusOK { t.Fatalf("预览应 200,实际 %d", rec3.Code) } if !strings.Contains(rec3.Body.String(), child2) { t.Errorf("★ 预览应列出将要写入的 %s(验证必须有不写库的路径),实际:%s", child2, rec3.Body.String()) } if err := db.DB.QueryRowContext(ctx, `SELECT parent_session_id FROM sessions WHERE session_id = ?`, child2).Scan(&got); err != nil { t.Fatalf("读父: %v", err) } if got.Valid { t.Errorf("★ 预览端点写了库(parent=%q)", got.String) } } // ★ 预览接口必须只读(不能含写操作)。 func TestPreviewEndpointIsReadOnly(t *testing.T) { b, err := os.ReadFile("../repo/session_tree.go") if err != nil { t.Fatalf("读 repo: %v", err) } src := string(b) i := strings.Index(src, "func PreviewSessionParents") if i < 0 { t.Fatal("找不到 PreviewSessionParents —— 接口化后验证必须有不写库的路径") } // ★ 按**行**取到下一个顶格 `func ` 为止,而不是 strings.Index("\nfunc ")。 // Index 那版在两次实测里都取错了段(一次取到别的函数的 UPDATE、 // 一次在函数是文件最后一个时落空而取了全文)—— 同一族「观察窗口不对」。 // 行扫描的语义就是我要的:这个函数体到下一个函数声明为止。 lines := strings.Split(src[i:], "\n") var body []string for _, ln := range lines { if len(body) > 0 && strings.HasPrefix(ln, "func ") { break } body = append(body, ln) } seg := strings.Join(body, "\n") // ★ 必须按**关键字边界**匹配,不能 strings.Contains。 // 初版判据对整段做 Contains("UPDATE"),而查询里的 // `ORDER BY c.updated_at DESC` 含有 "updated" ⇒ 判据把一个**只读**的 // 预览函数判成会写库。这已是本轮第三次「匹配比语义宽」: // 前两次是 .d.ts 的 `parent_mail_id = NULL` 命中注释、 // 以及 session-{id,log} 的正则多写了一个 `\.`。 for _, w := range []string{"INSERT", "UPDATE", "DELETE"} { re := regexp.MustCompile(`(?i)\b` + w + `\s`) if re.MatchString(seg) { t.Errorf("★ PreviewSessionParents 里出现 SQL 关键字 %s ⇒ 预览接口会写库", w) } } } var _ = http.StatusOK func setupTreeAdminHandlerDB(t *testing.T) { t.Helper() db.Close() path := filepath.Join(t.TempDir(), "tree-admin-handler.db") if err := db.Connect(context.Background(), "sqlite://"+path); err != nil { t.Fatalf("连接测试库: %v", err) } if err := db.Migrate(context.Background()); err != nil { t.Fatalf("迁移测试库: %v", err) } t.Cleanup(db.Close) } func mustTreeSession(t *testing.T, alias string) string { t.Helper() var id string if err := db.DB.QueryRowContext(context.Background(), `INSERT INTO sessions (session_alias, subject, status, workspace, from_agent) VALUES ($1,'x','active','/tmp','pi') RETURNING session_id`, alias).Scan(&id); err != nil { t.Fatalf("建会话: %v", err) } return id } func mustTreeMail(t *testing.T, session, subject, parent string) string { t.Helper() ctx := context.Background() var parentPtr *uuid.UUID if parent != "" { pid, err := uuid.Parse(parent) if err != nil { t.Fatalf("解析父邮件: %v", err) } parentPtr = &pid } sid, err := uuid.Parse(session) if err != nil { t.Fatalf("解析会话: %v", err) } if _, err := repo.CreateMail(ctx, sid, parentPtr, "pi", "/tmp", "dsh", "/tmp", subject, "", nil); err != nil { t.Fatalf("建邮件: %v", err) } var id string if err := db.DB.QueryRowContext(ctx, `SELECT mail_id FROM mails WHERE session_id = ? ORDER BY created_at DESC LIMIT 1`, session).Scan(&id); err != nil { t.Fatalf("取邮件: %v", err) } return id }