package web import ( "fmt" "net/http/httptest" "net/url" "os" "strings" "testing" "time" ) // S8 回归:GPX 轨迹必须按属主圈定。 // // gpx_tracks 带 user_id(为将来多用户留位),但两个入口都没用它: // - `GET /api/gpx` 是**公开**接口,却把整张表端出去; // - `POST /admin/gpx/delete` 的 id 来自客户端,只按 id 删就能删别人的。 // // 单用户站点上影响不大,但这是「越权」而不是「影响小」——而且两处都只差一个 user_id 条件。 func TestGpxOwnershipScoping(t *testing.T) { f := newMovementFixture(t) ctx, pg := f.ctx, f.pg var owner int64 if err := pg.QueryRowContext(ctx, `SELECT user_id FROM datasets WHERE id=$1`, f.dsID).Scan(&owner); err != nil { t.Fatalf("读数据集属主失败: %v", err) } // 外来账号:只为了拥有一条不属于本站主的轨迹 foreign := fmt.Sprintf("e2e-gpx-other-%d", time.Now().UnixNano()) var foreignID int64 if err := pg.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name) VALUES ($1,'x','gpx-other') RETURNING id`, foreign).Scan(&foreignID); err != nil { t.Fatalf("建外来账号失败: %v", err) } mkTrack := func(uid int64, name string) int64 { t.Helper() var id int64 if err := pg.QueryRowContext(ctx, `INSERT INTO gpx_tracks (user_id, name, point_count, length_km, points) VALUES ($1,$2,1,1.0,'[[40.0,116.0]]'::jsonb) RETURNING id`, uid, name).Scan(&id); err != nil { t.Fatalf("插入轨迹 %s 失败: %v", name, err) } return id } strayTrack := mkTrack(foreignID, "FOREIGN-"+foreign) ownTrack := mkTrack(owner, "OWNER-"+foreign) t.Cleanup(func() { pg.ExecContext(ctx, `DELETE FROM gpx_tracks WHERE id IN ($1,$2)`, strayTrack, ownTrack) pg.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, foreignID) }) // 1) 公开接口不许端出别人的轨迹。 // 判据不依赖「哪份数据集恰好是活跃的」:只要求返回的每条都属于活跃属主, // 而外来那条必须不在其中——后者才是安全不变量。 var activeOwner int64 if err := pg.QueryRowContext(ctx, `SELECT user_id FROM datasets WHERE is_active AND status='done' ORDER BY uploaded_at DESC LIMIT 1`).Scan(&activeOwner); err != nil { t.Fatalf("读活跃数据集属主失败: %v", err) } rec := httptest.NewRecorder() f.srv.apiGpx(rec, httptest.NewRequest("GET", "/api/gpx", nil)) if rec.Code != 200 { t.Fatalf("apiGpx 返回 %d: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() if strings.Contains(body, "FOREIGN-") { t.Errorf("公开接口把别的账号的轨迹也端出来了:%s", truncate(body, 300)) } if !strings.Contains(body, "OWNER-") { t.Fatalf("活跃数据集属主(user_id=%d)自己的轨迹没返回,用例失去意义:%s", activeOwner, truncate(body, 300)) } // 2) 删除别人账号的轨迹必须被拒,而且不能真删掉。 victim := httptest.NewRecorder() req := httptest.NewRequest("POST", "/admin/gpx/delete", strings.NewReader(url.Values{"id": {fmt.Sprint(strayTrack)}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") f.srv.adminGpxDelete(victim, req.WithContext(f.ctx)) if msg := flashMsg(t, victim); !strings.Contains(msg, "不存在") { t.Errorf("删别人的轨迹应当回「不存在」,实际 %q", msg) } var still int if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM gpx_tracks WHERE id=$1`, strayTrack).Scan(&still); err != nil { t.Fatal(err) } if still != 1 { t.Errorf("别人的轨迹被删掉了(id=%d)——越权删除", strayTrack) } // 3) 删自己的要能删掉(别为堵越权把正常路径也堵死了)。 okRec := httptest.NewRecorder() req = httptest.NewRequest("POST", "/admin/gpx/delete", strings.NewReader(url.Values{"id": {fmt.Sprint(ownTrack)}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") f.srv.adminGpxDelete(okRec, req.WithContext(f.ctx)) if msg := flashMsg(t, okRec); !strings.Contains(msg, "已删除") { t.Errorf("删自己的轨迹应当成功,实际 %q", msg) } var gone int if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM gpx_tracks WHERE id=$1`, ownTrack).Scan(&gone); err != nil { t.Fatal(err) } if gone != 0 { t.Errorf("自己的轨迹没被删掉(id=%d)", ownTrack) } } // truncate 只用于把失败信息压短,超长时截断。 func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" } // sqlText 取出函数体里所有反引号包的 SQL 片段。 // // 守卫必须只看 SQL:函数里的**中文注释**也会出现「user_id」这个词, // 直接 strings.Contains(body, "user_id") 会被注释顶包——变异掉真条件照样绿。 func sqlText(body string) string { var b strings.Builder for i := 0; ; { a := strings.IndexByte(body[i:], '`') if a < 0 { break } a += i z := strings.IndexByte(body[a+1:], '`') if z < 0 { break } z += a + 1 b.WriteString(body[a+1 : z]) b.WriteByte('\n') i = z + 1 } return b.String() } // S8 静态守卫:两个入口的 SQL 都必须带 user_id 条件。 // 行为用例要导一份备份包才能跑(约 6 秒);这条永远不 skip,防止「改回去」时静默回归。 func TestGpxQueriesCarryUserID(t *testing.T) { for _, c := range []struct { file string sig string want []string }{ {"handlers_public.go", "func (s *Server) apiGpx(", []string{"user_id"}}, {"handlers_admin.go", "func (s *Server) adminGpxDelete(", []string{"user_id"}}, } { data, err := os.ReadFile(pkgFile(t, c.file)) if err != nil { t.Fatalf("读 %s 失败: %v", c.file, err) } body := sliceFunc(t, string(data), c.sig) q := sqlText(body) if strings.TrimSpace(q) == "" { t.Fatalf("%s 的 %s 里没找到 SQL 字符串,守卫的切片判据需要更新", c.file, c.sig) } for _, w := range c.want { if !strings.Contains(q, w) { t.Errorf("%s 的 %s 的 SQL 里没有 %q:轨迹没按属主圈定", c.file, c.sig, w) } } } // 删不到时的反馈也要有断言:不然「删别人的」会回一句「已删除」。 data, err := os.ReadFile(pkgFile(t, "handlers_admin.go")) if err != nil { t.Fatal(err) } del := sliceFunc(t, string(data), "func (s *Server) adminGpxDelete(") if !strings.Contains(del, "RowsAffected") { t.Error("adminGpxDelete 没看受影响行数:删不到也会回「已删除」") } }