feat: 备份恢复 / 图片清理 / 单位恢复 / 审计过滤 / 模板引擎 M1 + 两个导入缺陷修复
用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷:
**新增能力**
- 备份恢复:`POST /api/backup/restore`(admin)。先把校验做在拍快照之前(只读打开 + integrity_check +
关键表校验 + 异类库标志表互斥),再用 VACUUM INTO 拍「恢复前安全副本」,然后关连接、整文件替换、
触发优雅重启;`-wal` 还在时直接拒绝(宁可失败也不让旧库 WAL 套到新库上);恢复后审计直写"恢复之后
还在的那份库"(不走通用 Auditor,否则记录只会落在被替换掉的旧文件上)。顺带修掉:裸服务端连续恢复
第二次会在拍副本处崩出一句看不懂的 500,现在回 503「上次恢复后尚未重启,请先重启服务端」。
- 安全副本保留策略:每类库保留最近 5 份,独立于 .db 的 20 份滚动清理。
- 图片管理:`DELETE /api/uploads/{name}`(被引用 409 并列出引用点)、`GET /api/uploads/orphans`、
`POST /api/uploads/orphans/delete`(只删列出的孤儿,宁漏勿误),全部 admin(与字体删除同档);
`deploy/backup.bat` 补上 uploads/ 归档(logs 不归档:可再生的诊断且会无上限增长)。
- 已彻底删除单位的恢复:`POST /api/teams/{id}/restore`(与 hideTeam 同档)。幂等;恢复不会把「停用」
变成「启用」;真机确认恢复前后榜单逐字节一致、change_log 最大值不变(数据一行没动,不需要重算)。
界面加「显示已彻底删除的单位」(仅 admin)与恢复按钮;非 admin 的删除确认框里写明"本账号无法自行恢复"。
- 审计日志按 `action` 过滤(子串模糊,输入里的 %/_/\ 转义成字面量)。
- 删除死表 recent_projects(新增 system 迁移 0006,未动已应用的迁移)。
- points_override 不再"设了没效果":写入侧 400 + 引擎侧守卫,提示改用「积分调整」。
- 联队份额空壳清理(UnionAttribution/UnionSplit/Entry.TeamShares/resolvedShares 等;TeamShare 类型保留,
因为 export/realtime/奖牌流水三处在生产路径上构造它)。
- 自定义大屏模板 M1:插值引擎(parseTemplate/renderTemplate),任何情况都不抛异常、未定义→空串+警告、
警告按 (code,path) 合并计数、无 eval/innerHTML,57 个新用例。
**两个真缺陷(前两个已随 0.1.6 出包)**
- 基础数据包导入「排序」列口径不一致:小项/组别留空保持原值,单位/大项留空被写成 0(plan 没从库里
现值起手)→ 四张表统一为「留空保持原值」。
- 同类第二处:布尔列「整列被删掉」被当成"取默认值",会把库里所有停用的单位/大项静默改回启用 →
改为「缺列 = 不动」(写侧与解析侧都收口,补了"缺列保持/空格子=是/显式否=停用"三组对照用例)。
**删死代码**
- createEntry / updateEntry / deleteEntry / entryEventID / checkCategoryInEvent / getEntry /
normalizeEntryStatus / ExportBaseDataXLSX(全仓 grep 零引用)。⚠️ 随之记入文档:「组别必须属于该小项」
这道校验现在只由导入路径"按小项+组别名解析"天然保证,将来若新增从请求体接收 categoryId 的接口,
必须自己补校验(库层没有复合外键)。
**文档**
- devdocs 全面同步(01/02/03/04/05/06/07/09/11/12/13/README;含身份口径、延迟结算、大屏单栏新版式、
新增路由与设置键、R18/R19 风险);docs 复核 + 第二轮(身份口径三个易写反的分支、图片删除、备份覆盖、
审计过滤、单位恢复、去掉硬编码版本号、上线检查清单 +5 项);debug/使用说明 6 份逐份 diff 一致。
测试:服务端 13 包(连跑两遍)、前端 406 例、Console 28 例 + 18 文件语法检查 —— 全绿。
版本号 dev_0.1.6 → dev_0.1.7。
This commit is contained in:
1 parent
631c8dd8e1
commit
53a0cb225c
61 files changed
+6386
-699
No files matched your search
@@ -12,6 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||
@@ -1462,3 +1463,352 @@ func TestEventBoolDefaultsFollowSchemaDefaults(t *testing.T) {
|
||||
assertStoredFlags(t, "E-BOOL-OFF", 0)
|
||||
assertStoredFlags(t, "E-BOOL", 0)
|
||||
}
|
||||
|
||||
// ---------- 上传图片的删除与孤儿清理 ----------
|
||||
|
||||
// uploadOrphansBody 对应 GET /api/uploads/orphans 的 data。
|
||||
type uploadOrphansBody struct {
|
||||
Items []uploadOrphanBody `json:"items"`
|
||||
Total int `json:"total"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
}
|
||||
|
||||
type uploadOrphanBody struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
type uploadDeleteFailureBody struct {
|
||||
Name string `json:"name"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type uploadDeleteBody struct {
|
||||
Deleted []string `json:"deleted"`
|
||||
Failed []uploadDeleteFailureBody `json:"failed"`
|
||||
}
|
||||
|
||||
// uploadAdminEnv 建好带项目目录的环境并登录 admin:
|
||||
// 删文件与孤儿清理是系统级资源管理,只有 admin 能做(与字体删除同一档)。
|
||||
func uploadAdminEnv(t *testing.T) (*testEnv, string, *http.Client) {
|
||||
t.Helper()
|
||||
env, uploadsDir := newUploadEnv(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
return env, uploadsDir, client
|
||||
}
|
||||
|
||||
// uploadOne 上传一张图片,返回 /uploads/<随机名>。
|
||||
func uploadOne(t *testing.T, env *testEnv, client *http.Client) string {
|
||||
t.Helper()
|
||||
status, body := env.postUpload(t, client, "logo.png", pngContent())
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("上传图片应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
return decodeData[uploadResultBody](t, body).Path
|
||||
}
|
||||
|
||||
func uploadBaseName(path string) string {
|
||||
return path[strings.LastIndex(path, "/")+1:]
|
||||
}
|
||||
|
||||
type teamCreatedBody struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// createTeamWithLogo 建一个挂 logo 的单位,返回 id;logo 引用点是 teams.logo_path。
|
||||
func createTeamWithLogo(t *testing.T, env *testEnv, client *http.Client, code, name, logo string) int64 {
|
||||
t.Helper()
|
||||
status, body := env.do(client, http.MethodPost, "/api/teams", map[string]any{
|
||||
"code": code, "name": name, "logoPath": logo, "active": true,
|
||||
}, true)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("建单位应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
return decodeData[teamCreatedBody](t, body).ID
|
||||
}
|
||||
|
||||
// putImageRef 改一处赛事设置的图片引用(path 传空串表示清除)。
|
||||
func putImageRef(t *testing.T, env *testEnv, client *http.Client, key, path string) {
|
||||
t.Helper()
|
||||
event := map[string]any{}
|
||||
display := map[string]any{}
|
||||
switch key {
|
||||
case "event.logo_path":
|
||||
event["logoPath"] = path
|
||||
case "display.logo":
|
||||
display["logo"] = path
|
||||
case "display.background":
|
||||
display["background"] = path
|
||||
default:
|
||||
t.Fatalf("未知的引用点 %s", key)
|
||||
}
|
||||
status, body := env.do(client, http.MethodPut, "/api/settings",
|
||||
// requestId 必须带上目标值:服务端按 body.requestId 做幂等回放,
|
||||
// 「先设成 A 再清空」用同一个 id 会让第二次直接回放第一次的响应,清空根本没落库。
|
||||
map[string]any{"event": event, "display": display, "requestId": "req-ref-" + key + "-" + path}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("设置 %s 应 200,得到 %d %+v", key, status, body.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func listOrphans(t *testing.T, env *testEnv, client *http.Client) uploadOrphansBody {
|
||||
t.Helper()
|
||||
status, body := env.do(client, http.MethodGet, "/api/uploads/orphans", nil, false)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("列孤儿图片应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
return decodeData[uploadOrphansBody](t, body)
|
||||
}
|
||||
|
||||
// 孤儿清单只列「没有任何引用点用到」的图片:赛事会标与单位图标都要被认出来。
|
||||
func TestListUploadOrphansOnlyUnreferenced(t *testing.T) {
|
||||
env, uploadsDir, client := uploadAdminEnv(t)
|
||||
|
||||
eventLogo := uploadOne(t, env, client)
|
||||
teamLogo := uploadOne(t, env, client)
|
||||
free := uploadOne(t, env, client)
|
||||
if names := uploadsDirEntries(t, uploadsDir); len(names) != 3 {
|
||||
t.Fatalf("应先落盘 3 张图片,实际 %v", names)
|
||||
}
|
||||
|
||||
putImageRef(t, env, client, "event.logo_path", eventLogo)
|
||||
createTeamWithLogo(t, env, client, "T1", "第一中学", teamLogo)
|
||||
|
||||
orphans := listOrphans(t, env, client)
|
||||
if orphans.Total != 1 || len(orphans.Items) != 1 {
|
||||
t.Fatalf("只应剩 1 张孤儿,得到 total=%d items=%+v", orphans.Total, orphans.Items)
|
||||
}
|
||||
item := orphans.Items[0]
|
||||
if item.Name != uploadBaseName(free) {
|
||||
t.Fatalf("孤儿应是未被引用的那张,得到 %q,期望 %q", item.Name, uploadBaseName(free))
|
||||
}
|
||||
if item.URL != free {
|
||||
t.Fatalf("孤儿 URL = %q,期望 %q", item.URL, free)
|
||||
}
|
||||
if item.Size != int64(len(pngContent())) {
|
||||
t.Fatalf("孤儿大小 = %d,期望 %d", item.Size, len(pngContent()))
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, item.ModifiedAt); err != nil {
|
||||
t.Fatalf("修改时间应是 RFC3339,得到 %q(%v)", item.ModifiedAt, err)
|
||||
}
|
||||
if orphans.TotalBytes != item.Size {
|
||||
t.Fatalf("总占用 = %d,期望 %d", orphans.TotalBytes, item.Size)
|
||||
}
|
||||
|
||||
// 把图配到大屏会标上,它就不再是孤儿。
|
||||
putImageRef(t, env, client, "display.logo", free)
|
||||
if after := listOrphans(t, env, client); after.Total != 0 || len(after.Items) != 0 {
|
||||
t.Fatalf("全部被引用后应没有孤儿,得到 %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有任何上传时是空清单而不是错误,便于前端直接渲染空态。
|
||||
func TestListUploadOrphansEmptyWhenNoUploads(t *testing.T) {
|
||||
env, _, client := uploadAdminEnv(t)
|
||||
orphans := listOrphans(t, env, client)
|
||||
if orphans.Total != 0 || orphans.TotalBytes != 0 || orphans.Items == nil {
|
||||
t.Fatalf("空目录应回空数组而不是 null:%+v(items=%#v)", orphans, orphans.Items)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除被引用的图片必须 409,并把「被谁引用」写进提示;被引用的文件不能被删掉。
|
||||
func TestDeleteUploadRejectsReferenced(t *testing.T) {
|
||||
env, uploadsDir, client := uploadAdminEnv(t)
|
||||
|
||||
eventLogo := uploadOne(t, env, client)
|
||||
displayLogo := uploadOne(t, env, client)
|
||||
background := uploadOne(t, env, client)
|
||||
teamLogo := uploadOne(t, env, client)
|
||||
createTeamWithLogo(t, env, client, "T1", "第一中学", teamLogo)
|
||||
putImageRef(t, env, client, "event.logo_path", eventLogo)
|
||||
putImageRef(t, env, client, "display.logo", displayLogo)
|
||||
putImageRef(t, env, client, "display.background", background)
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
label string
|
||||
}{
|
||||
{eventLogo, "赛事会标"},
|
||||
{displayLogo, "大屏会标"},
|
||||
{background, "大屏背景图"},
|
||||
{teamLogo, "单位「第一中学」的单位图标"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(c.path), nil, true)
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("删除被引用的 %s 应 409,得到 %d %+v", c.path, status, body.Error)
|
||||
}
|
||||
if body.Error == nil || !strings.Contains(body.Error.Message, c.label) {
|
||||
t.Fatalf("409 提示应说明引用位置 %q:%+v", c.label, body.Error)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(c.path))); err != nil {
|
||||
t.Fatalf("被引用的图片不能被删除 %s: %v", c.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 清掉引用后同一张图就能删掉,文件确实消失。
|
||||
putImageRef(t, env, client, "event.logo_path", "")
|
||||
if status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(eventLogo), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("不再被引用的图片应可删除,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(eventLogo))); !os.IsNotExist(err) {
|
||||
t.Fatalf("删除后文件应消失: %v", err)
|
||||
}
|
||||
|
||||
// 名字合法但文件不存在 → 404;非法文件名一律 404,不做任何删除尝试。
|
||||
if status, _ := env.do(client, http.MethodDelete, "/api/uploads/0123456789abcdef0123456789abcdef.png", nil, true); status != http.StatusNotFound {
|
||||
t.Fatalf("删除不存在的图片应 404,得到 %d", status)
|
||||
}
|
||||
if status, _ := env.do(client, http.MethodDelete, "/api/uploads/notes.txt", nil, true); status != http.StatusNotFound {
|
||||
t.Fatalf("非图片扩展名应 404,得到 %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// 被「彻底删除」(可恢复)的单位仍算引用:恢复后图标必须还在,所以不能当孤儿删掉。
|
||||
func TestUploadOrphansCountsHiddenTeam(t *testing.T) {
|
||||
env, _, client := uploadAdminEnv(t)
|
||||
logo := uploadOne(t, env, client)
|
||||
teamID := createTeamWithLogo(t, env, client, "T1", "第一中学", logo)
|
||||
|
||||
if status, body := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", teamID), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("彻底删除单位应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
if orphans := listOrphans(t, env, client); orphans.Total != 0 {
|
||||
t.Fatalf("隐藏单位的 logo 仍算被引用,不应进孤儿清单:%+v", orphans.Items)
|
||||
}
|
||||
if status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(logo), nil, true); status != http.StatusConflict {
|
||||
t.Fatalf("隐藏单位的 logo 应拒删,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除只动点名的文件:被引用的、不存在的、非法的逐条回报且不动文件。
|
||||
func TestDeleteUploadOrphansBatch(t *testing.T) {
|
||||
env, uploadsDir, client := uploadAdminEnv(t)
|
||||
used := uploadOne(t, env, client)
|
||||
orphanA := uploadOne(t, env, client)
|
||||
orphanB := uploadOne(t, env, client)
|
||||
createTeamWithLogo(t, env, client, "T1", "第一中学", used)
|
||||
|
||||
names := []string{uploadBaseName(orphanA), uploadBaseName(orphanB), uploadBaseName(used), "0123456789abcdef0123456789abcdef.png", "../config.yaml"}
|
||||
status, body := env.do(client, http.MethodPost, "/api/uploads/orphans/delete",
|
||||
map[string]any{"names": names, "requestId": "req-orphan-del-1"}, true)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("批量删除应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
result := decodeData[uploadDeleteBody](t, body)
|
||||
if len(result.Deleted) != 2 || result.Deleted[0] != uploadBaseName(orphanA) || result.Deleted[1] != uploadBaseName(orphanB) {
|
||||
t.Fatalf("只应删掉两张孤儿,得到 %+v", result.Deleted)
|
||||
}
|
||||
if len(result.Failed) != 3 {
|
||||
t.Fatalf("应有 3 条失败回报,得到 %+v", result.Failed)
|
||||
}
|
||||
reasonOf := func(name string) string {
|
||||
for _, item := range result.Failed {
|
||||
if item.Name == name {
|
||||
return item.Reason
|
||||
}
|
||||
}
|
||||
t.Fatalf("失败回报里缺少 %s:%+v", name, result.Failed)
|
||||
return ""
|
||||
}
|
||||
if reason := reasonOf(uploadBaseName(used)); !strings.Contains(reason, "仍被引用") || !strings.Contains(reason, "单位「第一中学」") {
|
||||
t.Fatalf("被引用条目的原因应点名引用位置,得到 %q", reason)
|
||||
}
|
||||
if reason := reasonOf("0123456789abcdef0123456789abcdef.png"); !strings.Contains(reason, "文件不存在") {
|
||||
t.Fatalf("缺失文件应报不存在,得到 %q", reason)
|
||||
}
|
||||
if reason := reasonOf("../config.yaml"); !strings.Contains(reason, "文件名非法") {
|
||||
t.Fatalf("非法文件名应被拒,得到 %q", reason)
|
||||
}
|
||||
|
||||
for _, name := range []string{uploadBaseName(orphanA), uploadBaseName(orphanB)} {
|
||||
if _, err := os.Stat(filepath.Join(uploadsDir, name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("孤儿 %s 应已删除: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(used))); err != nil {
|
||||
t.Fatalf("被引用的图片不能被批量删除: %v", err)
|
||||
}
|
||||
var audits int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'uploads.delete'`).Scan(&audits); err != nil {
|
||||
t.Fatalf("查询删除审计: %v", err)
|
||||
}
|
||||
if audits != 1 {
|
||||
t.Fatalf("批量删除应写一条 uploads.delete 审计,得到 %d", audits)
|
||||
}
|
||||
|
||||
// 空清单直接拒绝,不能让「没传 names」变成「什么都没删但显示成功」。
|
||||
if status, _ := env.do(client, http.MethodPost, "/api/uploads/orphans/delete",
|
||||
map[string]any{"names": []string{}, "requestId": "req-orphan-del-2"}, true); status != http.StatusBadRequest {
|
||||
t.Fatalf("空 names 应 400,得到 %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除与清理是系统级资源管理:guest 401、recorder 403。
|
||||
func TestUploadDeleteRequiresAdmin(t *testing.T) {
|
||||
env, uploadsDir, admin := uploadAdminEnv(t)
|
||||
orphan := uploadBaseName(uploadOne(t, env, admin))
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
|
||||
guest, _ := env.client()
|
||||
// 各自用对应的方法:GET 那条走的是 /api/uploads/{name} 之外的兜底路由,用错方法量到的是 404 而不是权限。
|
||||
if status, _ := env.do(guest, http.MethodGet, "/api/uploads/orphans", nil, false); status != http.StatusForbidden {
|
||||
t.Fatalf("guest 读孤儿清单应 403,得到 %d", status)
|
||||
}
|
||||
if status, _ := env.do(guest, http.MethodDelete, "/api/uploads/"+orphan, nil, false); status != http.StatusUnauthorized {
|
||||
t.Fatalf("guest 删图片应 401,得到 %d", status)
|
||||
}
|
||||
if status, _ := env.do(guest, http.MethodPost, "/api/uploads/orphans/delete", map[string]any{"names": []string{orphan}}, false); status != http.StatusUnauthorized {
|
||||
t.Fatalf("guest 批量删孤儿应 401,得到 %d", status)
|
||||
}
|
||||
|
||||
recorder, _ := env.client()
|
||||
env.login(recorder, "recorder", "recorder-pass")
|
||||
if status, _ := env.do(recorder, http.MethodGet, "/api/uploads/orphans", nil, false); status != http.StatusForbidden {
|
||||
t.Fatalf("recorder 读孤儿清单应 403,得到 %d", status)
|
||||
}
|
||||
if status, _ := env.do(recorder, http.MethodDelete, "/api/uploads/"+orphan, nil, true); status != http.StatusForbidden {
|
||||
t.Fatalf("recorder 删图片应 403,得到 %d", status)
|
||||
}
|
||||
if status, _ := env.do(recorder, http.MethodPost, "/api/uploads/orphans/delete",
|
||||
map[string]any{"names": []string{orphan}, "requestId": "req-orphan-rec"}, true); status != http.StatusForbidden {
|
||||
t.Fatalf("recorder 批量删孤儿应 403,得到 %d", status)
|
||||
}
|
||||
// 权限不足的请求不能有任何副作用:那张孤儿必须还在。
|
||||
if _, err := os.Stat(filepath.Join(uploadsDir, orphan)); err != nil {
|
||||
t.Fatalf("权限不足的删除请求不该动文件: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除接口不能靠路径穿越打到 uploads 之外:uploads 同级的图片扩展名文件必须毫发无损。
|
||||
func TestDeleteUploadRejectsTraversal(t *testing.T) {
|
||||
env, uploadsDir, client := uploadAdminEnv(t)
|
||||
projectDir := filepath.Dir(uploadsDir)
|
||||
const secret = "outside-secret"
|
||||
if err := os.MkdirAll(projectDir, 0o755); err != nil {
|
||||
t.Fatalf("创建项目目录: %v", err)
|
||||
}
|
||||
outside := filepath.Join(projectDir, "secret.png")
|
||||
if err := os.WriteFile(outside, []byte(secret), 0o644); err != nil {
|
||||
t.Fatalf("写 uploads 之外的文件: %v", err)
|
||||
}
|
||||
|
||||
paths := []string{
|
||||
"/api/uploads/..%2Fsecret.png",
|
||||
"/api/uploads/%2e%2e%2Fsecret.png",
|
||||
"/api/uploads/a%2Fb.png",
|
||||
}
|
||||
for _, path := range paths {
|
||||
status, _ := env.do(client, http.MethodDelete, path, nil, true)
|
||||
if status == http.StatusOK {
|
||||
t.Fatalf("%s 不应删成功", path)
|
||||
}
|
||||
if body, err := os.ReadFile(outside); err != nil || string(body) != secret {
|
||||
t.Fatalf("uploads 之外的文件被动了 %s: %v %q", path, err, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user