用户 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 包全绿。
249 lines
8.5 KiB
Go
249 lines
8.5 KiB
Go
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
|
||
}
|