package web import ( "os" "strings" "testing" ) // ---------- F5:地图脚本里的 localStorage 访问必须走兜底 ---------- // // 隐私模式 / 禁用 Cookie 的浏览器里 localStorage 读写都会抛异常,裸调用会把整段脚本打断 // (地图白屏、图层不出来,且控制台之外看不出来)。统一走 lsGet/lsSet。 func TestMapLocalStorageGoesThroughGuard(t *testing.T) { b, err := staticFS.ReadFile("static/js/rond-map.js") if err != nil { t.Fatalf("读取 rond-map.js 失败: %v", err) } src := string(b) if !strings.Contains(src, "function lsGet(k)") || !strings.Contains(src, "function lsSet(k, v)") { t.Fatal("rond-map.js 里找不到 lsGet/lsSet 兜底(写法变了,这条守卫失效)") } if n := strings.Count(src, "window.localStorage."); n != 2 { t.Errorf("裸 localStorage 访问应只剩 lsGet/lsSet 里那 2 处,实际 %d 处"+ "(其余一律要走 lsGet/lsSet,否则隐私模式下整段脚本会抛异常)", n) } } // ---------- F6:配置未就绪时的第二次 preview 不能丢点 ---------- func TestMapPreviewQueuesPendingPoints(t *testing.T) { b, err := staticFS.ReadFile("static/js/rond-map.js") if err != nil { t.Fatalf("读取 rond-map.js 失败: %v", err) } src := string(b) body := funcBody(src, "preview: function (opts)") if body == "" { t.Fatal("在 rond-map.js 里找不到 preview(写法变了,这条守卫失效)") } queue := strings.Index(body, "previewQueue[opts.id] = points;") live := strings.Index(body, "if (live) {") if queue < 0 || live < 0 || queue > live { t.Error("preview:必须在 `if (live)` 之前就把本次的点记进 previewQueue," + "否则配置未就绪时第二次 preview 的点会被静默丢掉") } if !strings.Contains(body, "if (previewQueue[opts.id]) points = previewQueue[opts.id];") { t.Error("preview:配置回调里没把 previewQueue 排队的点取回来画") } if strings.Contains(src, "previewPending") { t.Error("rond-map.js 里不该再有 previewPending(已由 previewQueue 取代)") } } // ---------- F8:EXIF 全 0 时间不能变成 1899-11-30 ---------- func TestExifRejectsOutOfRangeDate(t *testing.T) { b, err := staticFS.ReadFile("static/js/photo-exif.js") if err != nil { t.Fatalf("读取 photo-exif.js 失败: %v", err) } src := string(b) guard := strings.Index(src, "Y >= 1900") mk := strings.Index(src, "res.shot = new Date(Y, Mo - 1, D, H, Mi, S);") flag := strings.Index(src, "res.hasTime = true;") if guard < 0 || mk < 0 || flag < 0 || guard > mk || guard > flag { t.Error("photo-exif.js:拍摄时间必须先过范围校验再赋给 res.shot / hasTime" + "(否则「0000:00:00」会被 Date 归一成 1899-11-30,被当成到达时间写进库)") } } // ---------- F9:findExifItemID 读 count 前必须卡住宽度 ---------- func TestExifItemIDChecksBoundsBeforeRead(t *testing.T) { b, err := staticFS.ReadFile("static/js/photo-exif.js") if err != nil { t.Fatalf("读取 photo-exif.js 失败: %v", err) } body := funcBody(string(b), "function findExifItemID(") if body == "" { t.Fatal("在 photo-exif.js 里找不到 findExifItemID(写法变了,这条守卫失效)") } bounds := strings.Index(body, "p + 2 > u8.length") read := strings.Index(body, "dv.getUint16(p)") if bounds < 0 || read < 0 || bounds > read { t.Error("findExifItemID:读 count 之前必须按 ver 卡住宽度(p+2 / p+4)——" + "末尾恰好相等(start+4 == length)时旧守卫会放行,越界会抛 RangeError") } } // ---------- F16:数据编辑页每个筛选表单都要带上全部筛选条件 ---------- // // 三个表单各自只放自己那几个控件,其余条件靠隐藏域带着;漏一个,用户一提交 // 就会把别的列表的筛选悄悄清掉(原来「到访」表单只带 vq+back)。 func TestEditSearchFormsCarryAllFilters(t *testing.T) { b, err := tmplFS.ReadFile("templates/admin/edit.html") if err != nil { t.Fatalf("读取 edit.html 失败: %v", err) } src := string(b) const open = `
") if j < 0 { t.Fatalf("第 %d 个筛选表单没有收尾", n) } body := rest[:j] for _, p := range params { if !strings.Contains(body, p) { t.Errorf("第 %d 个筛选表单少了 %s:一提交就会把别的列表的筛选清掉", n, p) } } rest = rest[j:] } if n < 3 { t.Fatalf("只找到 %d 个 /admin/edit 筛选表单,预期至少 3 个(写法变了,这条守卫失效)", n) } } // ---------- F17:`safe` 这个现成的转义出口不该留着 ---------- func TestNoSafeEscapeFunc(t *testing.T) { b, err := os.ReadFile(pkgFile(t, "view.go")) if err != nil { t.Fatalf("读取 view.go 失败: %v", err) } if strings.Contains(string(b), `"safe":`) { t.Error("view.go 里不该再有 `safe` 模板函数(模板一处都没用,却是个现成的 HTML 注入出口)") } } // ---------- F18:纯图标按钮要有 aria-label,图片要有 alt ---------- func TestPlaceImageControlsAreLabelled(t *testing.T) { b, err := tmplFS.ReadFile("templates/place.html") if err != nil { t.Fatalf("读取 place.html 失败: %v", err) } src := string(b) if !strings.Contains(src, `aria-label="删除这张图片"`) { t.Error("place.html 模板渲染的删除按钮(×)缺 aria-label") } if !strings.Contains(src, `setAttribute('aria-label', '删除这张图片')`) { t.Error("place.html 用 JS 新建的删除按钮(×)缺 aria-label") } if strings.Contains(src, `img.alt = ''`) { t.Error("place.html 新建缩略图时 alt 不能留空") } } // ---------- F14:窄屏导航链接的内边距别再写死一处(会被后面的触控块盖掉) ---------- func TestNavLinksNarrowRuleHasNoDeadPadding(t *testing.T) { b, err := os.ReadFile(pkgFile(t, "static/css/app.css")) if err != nil { t.Fatalf("读取 app.css 失败: %v", err) } src := string(b) if !strings.Contains(src, ".nav-links a { font-size: 13px; }") { t.Fatal("找不到窄屏的 `.nav-links a { font-size: 13px; }`(写法变了,这条守卫失效)") } if strings.Contains(src, ".nav-links a { padding:") { t.Error("窄屏 .nav-links a 又写了 padding:同特异性的内边距由后面「窄屏触控」那块给," + "写在这里会被盖掉(死代码)") } } // ---------- F10:补全页的 sessionStorage 读写都要包在 try 里 ---------- // // 隐私模式下 sessionStorage 本身就抛,脏数据又会让 JSON.parse 抛;任何一处漏了 try, // 整段脚本就从那一行断掉——下拉候选、草稿恢复全部哑掉,而且控制台之外看不出来。 func TestFillupSessionStorageIsGuarded(t *testing.T) { b, err := tmplFS.ReadFile("templates/admin/fillup.html") if err != nil { t.Fatalf("读取 fillup.html 失败: %v", err) } src := string(b) needles := []string{"JSON.parse(sessionStorage", "sessionStorage.setItem"} total := 0 for _, n := range needles { rest := src for { i := strings.Index(rest, n) if i < 0 { break } total++ if open := strings.LastIndex(rest[:i], "try {"); open < 0 || i-open > 160 { t.Errorf("fillup.html:`%s` 前面没有紧挨着的 try {(隐私模式 / 脏数据会中断整段脚本)", n) } rest = rest[i+len(n):] } } if total == 0 { t.Fatal("fillup.html 里找不到 sessionStorage 读写(写法变了,这条守卫失效)") } }