package web import ( "bytes" "context" "database/sql" "fmt" "image" "image/png" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "time" "rond-with-you/internal/auth" "rond-with-you/internal/config" "rond-with-you/internal/db" "rond-with-you/internal/fog" ) // fogPreviewFixture 是一个迷雾为空、带一次性账号的测试库。 // // ROND_TEST_CONF=conf/app-test.ini go test ./internal/web -run FogPreview -v // // 迷雾是站点级数据(公开读路径不区分归属),所以测试必须从空库出发; // 用的是独立测试库(rond_fog_test),不会碰到开发库里的迷雾。 type fogPreviewFixture struct { cfg *config.Config pg *sql.DB srv *Server ctx context.Context uid int64 ix *fog.Index } func newFogPreviewFixture(t *testing.T) *fogPreviewFixture { t.Helper() confPath := os.Getenv("ROND_TEST_CONF") if confPath == "" { t.Skip("设置 ROND_TEST_CONF 后运行迷雾导入校验") } chdirModuleRoot(t) cfg, err := config.Load(confPath) if err != nil { t.Fatalf("加载配置 %s 失败: %v", confPath, err) } ctx := context.Background() pg, err := db.Open(cfg.DSN) if err != nil { t.Fatalf("连接数据库失败: %v", err) } t.Cleanup(func() { pg.Close() }) if err := db.Migrate(ctx, pg); err != nil { t.Fatalf("初始化数据库失败: %v", err) } user := fmt.Sprintf("e2e-fogpreview-%d", time.Now().UnixNano()) hash, err := auth.HashPassword("e2e-password") if err != nil { t.Fatalf("生成口令哈希失败: %v", err) } var uid int64 if err := pg.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name) VALUES ($1,$2,'e2e') RETURNING id`, user, hash).Scan(&uid); err != nil { t.Fatalf("创建测试账号失败: %v", err) } clearFog(t, ctx, pg) t.Cleanup(func() { clearFog(t, ctx, pg) pg.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, uid) os.RemoveAll(filepath.Join(cfg.DataDir, "fog-staging")) fogResetIndex() }) srv, err := New(cfg, pg) if err != nil { t.Fatalf("初始化服务失败: %v", err) } f := &fogPreviewFixture{cfg: cfg, pg: pg, srv: srv, uid: uid, ctx: ctxWithUser(&auth.User{ID: uid, Username: user})} f.ix = f.reloadIndex(t) return f } func clearFog(t *testing.T, ctx context.Context, pg *sql.DB) { t.Helper() for _, q := range []string{`DELETE FROM fog_blocks`, `DELETE FROM fog_raw_files`} { if _, err := pg.ExecContext(ctx, q); err != nil { t.Fatalf("清空迷雾失败: %v", err) } } } // reloadIndex 让内存索引与库对齐:fogIndex 的首次加载只跑一次(sync.Once), // 测试里反复清库写库,必须手动同步,否则差异页算的是上一轮的残留。 func (f *fogPreviewFixture) reloadIndex(t *testing.T) *fog.Index { t.Helper() ix, err := f.srv.fogIndex() if err != nil { t.Fatalf("获取迷雾索引失败: %v", err) } if err := ix.LoadAll(f.pg); err != nil { t.Fatalf("重载迷雾索引失败: %v", err) } return ix } // upload 走后台真实的上传处理函数,返回差异页的令牌。 func (f *fogPreviewFixture) upload(t *testing.T, data []byte) string { t.Helper() rec := httptest.NewRecorder() f.srv.adminFogPreview(rec, fogUploadRequest(t, data).WithContext(f.ctx)) if rec.Code != http.StatusSeeOther { t.Fatalf("上传应跳转到差异页: code=%d body=%s", rec.Code, rec.Body.String()) } loc := rec.Header().Get("Location") token := strings.TrimPrefix(loc, "/admin/fog/preview/") if token == loc || len(token) != fogStashTokenLen { t.Fatalf("差异页地址不对: %q", loc) } return token } // preview 渲染差异页。 func (f *fogPreviewFixture) preview(t *testing.T, token string) string { t.Helper() rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/admin/fog/preview/"+token, nil).WithContext(f.ctx) req.SetPathValue("token", token) f.srv.adminFogPreviewPage(rec, req) if rec.Code != http.StatusOK { t.Fatalf("差异页渲染失败: code=%d body=%s", rec.Code, rec.Body.String()) } return rec.Body.String() } // confirm 走差异页确认按钮那条路径:带 stash 令牌、只提交 mode。 func (f *fogPreviewFixture) confirm(t *testing.T, token, mode string) { t.Helper() rec := httptest.NewRecorder() req := httptest.NewRequest("POST", "/admin/fog?stash="+token, strings.NewReader("mode="+mode)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") f.srv.adminFogUpload(rec, req.WithContext(f.ctx)) if rec.Code != http.StatusSeeOther { t.Fatalf("确认导入应跳回后台: code=%d body=%s", rec.Code, rec.Body.String()) } if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/admin?ok=") { t.Fatalf("确认导入应报成功: %s", loc) } } func (f *fogPreviewFixture) cells(t *testing.T) int { t.Helper() _, cells := f.reloadIndex(t).Stats() return cells } func (f *fogPreviewFixture) blocks(t *testing.T) int { t.Helper() blocks, _ := f.reloadIndex(t).Stats() return blocks } // TestFogPreviewShowsDiffBeforeWriting 端到端验证「上传 → 差异页 → 确认导入」: // 差异页必须先把两种模式的后果摆出来,而且在上传那一刻格子里一行都不能写。 func TestFogPreviewShowsDiffBeforeWriting(t *testing.T) { f := newFogPreviewFixture(t) // 库里先有一块 3 格的区域 if _, err := f.ix.Import(f.pg, f.uid, []fog.Block{ newFogBlock(100, 200, [2]int{0, 0}, [2]int{1, 0}, [2]int{2, 0}), }, false); err != nil { t.Fatalf("预置迷雾失败: %v", err) } // 新快照:同一块补亮 2 格,外加一整块新的 inc := []fog.Block{ newFogBlock(100, 200, [2]int{0, 0}, [2]int{1, 0}, [2]int{2, 0}, [2]int{5, 5}, [2]int{6, 5}), newFogBlock(101, 200, [2]int{1, 1}), } data, err := fog.Encode(inc, map[string][]byte{ "Model/~/aaa": {1, 2, 3}, "Model/#/bbb": {4, 5, 6}, }) if err != nil { t.Fatalf("构造快照失败: %v", err) } token := f.upload(t, data) // 差异页只是预览:格数必须还是 3 if got := f.cells(t); got != 3 { t.Fatalf("上传阶段不该写库,当前格数 %d", got) } page := f.preview(t, token) for _, want := range []string{ "迷雾导入预览", "叠加导入", "整体替换", "会补上当前迷雾没有的", // 叠加卡片给出的实际收益 "替换不会丢数据", // 库里的 3 格都在快照里 "Model/~", // 图层提示 "/admin/fog?stash=" + token, `value="overlay"`, `value="cover"`, } { if !strings.Contains(page, want) { t.Errorf("差异页缺少 %q", want) } } if strings.Contains(page, "完全覆盖") { t.Error("这份快照有新内容,不该报「已被完全覆盖」") } // 确认叠加:3 格 + 补亮的 2 格 + 新块 1 格 = 6 格,两块 f.confirm(t, token, "overlay") if got := f.cells(t); got != 6 { t.Fatalf("叠加后应有 6 格,实际 %d", got) } if got := f.blocks(t); got != 2 { t.Fatalf("叠加后应有 2 块,实际 %d", got) } } // TestFogPreviewWarnsBeforeCoverLosesData 整体替换必须在下手前算出「会丢多少」, // 并且差异页要看得出这份快照已经被完全覆盖过(叠加没有意义)。 func TestFogPreviewWarnsBeforeCoverLosesData(t *testing.T) { f := newFogPreviewFixture(t) // 库里两块共 5 格 if _, err := f.ix.Import(f.pg, f.uid, []fog.Block{ newFogBlock(100, 200, [2]int{0, 0}, [2]int{1, 0}, [2]int{2, 0}), newFogBlock(101, 200, [2]int{7, 7}, [2]int{8, 8}), }, false); err != nil { t.Fatalf("预置迷雾失败: %v", err) } // 快照只有第一块、且一格不多:叠加零收益,替换会丢掉第二块的两格 inc := []fog.Block{newFogBlock(100, 200, [2]int{0, 0}, [2]int{1, 0}, [2]int{2, 0})} data, err := fog.Encode(inc, nil) if err != nil { t.Fatalf("构造快照失败: %v", err) } token := f.upload(t, data) page := f.preview(t, token) for _, want := range []string{ "已经被当前迷雾", // 叠加无收益 "从点亮变回未探索", "Model/~", // 快照没有这两层,替换会清掉现有的 } { if !strings.Contains(page, want) { t.Errorf("差异页缺少 %q", want) } } // 确认整体替换:只该剩下快照里的 1 块 3 格 f.confirm(t, token, "cover") if got := f.cells(t); got != 3 { t.Fatalf("替换后应有 3 格,实际 %d", got) } if got := f.blocks(t); got != 1 { t.Fatalf("替换后应有 1 块,实际 %d", got) } // 暂存件用掉就该删掉,且过期令牌不该还能用 if _, err := os.Stat(filepath.Join(f.cfg.DataDir, "fog-staging", token+".fwss")); !os.IsNotExist(err) { t.Error("确认导入后应删除暂存文件") } rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/admin/fog/preview/"+token, nil).WithContext(f.ctx) req.SetPathValue("token", token) f.srv.adminFogPreviewPage(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("过期令牌应跳回后台报错: code=%d", rec.Code) } } // 差异瓦片要按请求坐标渲染出正确的三色,并挡住越界坐标与过期令牌。 func TestFogDiffTileColors(t *testing.T) { f := newFogPreviewFixture(t) if _, err := f.ix.Import(f.pg, f.uid, []fog.Block{ newFogBlock(100, 200, [2]int{0, 0}, [2]int{4, 0}), }, false); err != nil { t.Fatalf("预置迷雾失败: %v", err) } data, err := fog.Encode([]fog.Block{newFogBlock(100, 200, [2]int{0, 0}, [2]int{1, 0})}, nil) if err != nil { t.Fatalf("构造快照失败: %v", err) } token := f.upload(t, data) // z=18 时一格 16 像素,块 (100,200) 完整落在瓦片 (400,800) 里 img := f.diffTile(t, token, 18, 400, 800) if got := kindOf(pixAtImg(img, 8, 8)); got != "both" { t.Errorf("两边都有的格应为灰色,实际 %s", got) } if got := kindOf(pixAtImg(img, 24, 8)); got != "gain" { t.Errorf("只有快照有的格应为绿色,实际 %s", got) } if got := kindOf(pixAtImg(img, 72, 8)); got != "lost" { t.Errorf("只有库里有、替换会丢的格应为红色,实际 %s", got) } if got := kindOf(pixAtImg(img, 200, 200)); got != "none" { t.Errorf("没探索过的格应为透明,实际 %s", got) } // 越界坐标与过期令牌都只给 404,不能穿透到渲染 for _, c := range []struct{ z, x, y string }{ {"18", "262144", "0"}, // x 已达该级别的上限 {"18", "0", "-1"}, {"99", "0", "0"}, // z 越界 } { rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/admin/fog/preview/"+token+"/x", nil).WithContext(f.ctx) req.SetPathValue("token", token) req.SetPathValue("z", c.z) req.SetPathValue("x", c.x) req.SetPathValue("y", c.y) f.srv.adminFogDiffTile(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("越界坐标 %v 应给 404,实际 %d", c, rec.Code) } } rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/admin/fog/preview/deadbeef/18/400/800", nil).WithContext(f.ctx) req.SetPathValue("token", strings.Repeat("f", 32)) req.SetPathValue("z", "18") req.SetPathValue("x", "400") req.SetPathValue("y", "800") f.srv.adminFogDiffTile(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("无效令牌应给 404,实际 %d", rec.Code) } } func (f *fogPreviewFixture) diffTile(t *testing.T, token string, z, x, y int) image.Image { t.Helper() rec := httptest.NewRecorder() req := httptest.NewRequest("GET", fmt.Sprintf("/admin/fog/preview/%s/%d/%d/%d?crs=wgs84", token, z, x, y), nil). WithContext(f.ctx) req.SetPathValue("token", token) req.SetPathValue("z", strconv.Itoa(z)) req.SetPathValue("x", strconv.Itoa(x)) req.SetPathValue("y", strconv.Itoa(y)) f.srv.adminFogDiffTile(rec, req) if rec.Code != http.StatusOK { t.Fatalf("差异瓦片渲染失败: code=%d", rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "image/png" { t.Fatalf("差异瓦片应为 PNG,实际 %q", ct) } img, err := png.Decode(bytes.NewReader(rec.Body.Bytes())) if err != nil { t.Fatalf("解码差异瓦片失败: %v", err) } return img } // kindOf 按「哪个通道最亮」判类别,不去比对精确色值——PNG 解码回来的是 // 预乘 alpha 的像素,硬比色值会被 alpha 换算绕进去。 func kindOf(c [4]byte) string { switch { case c[3] == 0: return "none" case c[1] > c[0] && c[1] > c[2]: return "gain" case c[0] > c[1] && c[0] > c[2]: return "lost" } return "both" } func pixAtImg(img image.Image, px, py int) [4]byte { r, g, b, a := img.At(px, py).RGBA() return [4]byte{byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8)} } // 令牌来自 URL,必须挡死路径穿越。 func TestFogStashTokenRejectsTraversal(t *testing.T) { s := &Server{cfg: &config.Config{DataDir: t.TempDir()}} for _, bad := range []string{"", "..", "../../etc/passwd", strings.Repeat("a", 32) + "/../x", strings.ToUpper(strings.Repeat("a", 32))} { if p, ok := s.fogStashPath(bad); ok { t.Errorf("令牌 %q 不该通过校验,得到 %s", bad, p) } } if _, ok := s.fogStashPath(strings.Repeat("0", 32)); !ok { t.Error("32 位十六进制令牌应当通过") } } func newFogBlock(gx, gy int, cells ...[2]int) fog.Block { bm := make([]byte, 512) for _, c := range cells { bm[c[1]*8+c[0]/8] |= 0x80 >> (c[0] % 8) } return fog.Block{GX: gx, GY: gy, Bitmap: bm} } func fogUploadRequest(t *testing.T, data []byte) *http.Request { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, err := mw.CreateFormFile("fwss", "Snapshot-test.fwss") if err != nil { t.Fatalf("构造上传表单失败: %v", err) } if _, err := fw.Write(data); err != nil { t.Fatalf("写入上传内容失败: %v", err) } mw.Close() req := httptest.NewRequest("POST", "/admin/fog/preview", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) return req }