Files
MailUI4Agents/server/internal/handler/session_tree_admin_test.go
JianFeeeee dfd5661e24 refactor(线索树): 回填接口化 —— 数据操作不进结构迁移通道
用户 2026-10-04:「一切数据调用都要接口化」。这条要求直接指向上一轮的两个错误。

## 撤掉的:Migrate 里的自动回填

`Migrate` 是**结构**变更通道(建表、加列)。让��兼做数据改写(给会话写
parent_session_id)是混用通道,后果不是理论上的:

- 不可重跑 —— 上一轮那次回填已经在部署中执行,`app_meta` 标记
  `2026-10-04T02:54:37Z`,**无法通过任何方式重跑或撤销**;
- 不可审计 —— 库上看不出谁在什么时候改的;
- 不可触发 —— 没有接口能主动执行它。

⇒ 移到 admin 端点,与 `DELETE /admin/sessions/{id}`、
`POST /admin/agent-keys` 同一档。

## 加的

    POST /api/v1/admin/sessions/tree/backfill    默认 dry-run,显式 dry_run=0 才写
    GET  /api/v1/admin/sessions/tree/preview     只读

`PreviewSessionParents`(repo 层,只读)是必需的配套:接口化之后**验证本身
也必须走接口**,那么「回填到底会改什么」就得有一条不写任何东西的接口来回答,
否则只剩「先写了再看看对不对」。

## 判据抓到的真 bug:注释说默认 dry-run,代码默认就写

    if v := r.URL.Query().Get("dry_run"); v != "" {   // 参数缺失 → 整个 if 落空
        ...
    }
    n, err := repo.BackfillSessionParents(...)         // ← 直接执行

误点一次 POST 就改了 6 条真实会话的拓扑,且没有任何预览。修成
`dry := true` 起手、只在显式解析出 false 时才写。

## 判据自己假绿了一次(本轮第四次做形状判据)

`TestBackfillDefaultsToDryRun` 初版读源码、数 `AdminPreviewSessionParents`
出现几次。变异 `dry := false`(**正是上面那个 bug**)之后判据**依然全绿** ——
因为 `if dry {…}` 分支在源码里始终存在。

⇒ 改成行为测试:真起 httptest 发一个不带参数的 POST,然后去**库里**看
parent_session_id 有没有被写。重测同一变异,两条断言都红:

    ★ 无参 POST 的响应应表明这是预览,实际:{"dry_run":false,…}
    ★ 无参 POST 就把 parent_session_id 写成了 "31949dc4-…"

同轮修掉判据两次「匹配比语义宽」:`strings.Contains(seg, "UPDATE")`
把 `ORDER BY c.updated_at DESC` 里的 `updated` 当成了写库;
函数边界用 `strings.Index("\nfunc ")` 在「函数是文件最后一个」时落空,
取到全文后把别的函数里的 UPDATE 判成预览写库。

## 尚未验证

树端点只能验到「匿名被正确拒绝(401)」—— **不足以证明树能用**。
缺 admin 凭证,admin 全看 vs 普通用户收窄的差异还没在真数据上跑过。

14 包全绿。
2026-10-04 12:03:58 +08:00

249 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}