- users 表加 password_hash 列;后台账号(owner+admin)密码 bcrypt 存行内, 首次登录把 env / settings 引导凭据自迁移成行哈希 - 会话 token 从用户名改为携带用户 ID,角色与停用状态每请求查库, 改角色 / 停用账号即时生效(存量会话立即 401) - 登录:先查 users 表,再走 settings 哈希 / env 引导链; admin/admin 开发模式在任何账号设过密码后失效 - 权限:系统设置、用户管理仅 owner;内容管理 admin+owner; admin 后台新增 用户 页(创建 / 重置密码 / 停用 / 删除), 设置页「登录与存储」tab 对管理员隐藏 - 账户页加修改密码表单(旧密码校验,OAuth/Passkey 首设免旧密码); 评论区管理员身份跟随各自账号,不再统一挂站主名下 - 修复:providers 为 nil 时账户页白屏(Go nil slice 序列化成 null)
148 lines
4.4 KiB
Go
148 lines
4.4 KiB
Go
package admin
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"oneblog/internal/model"
|
|
"oneblog/internal/storage"
|
|
"oneblog/internal/store"
|
|
)
|
|
|
|
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
|
|
|
// seed 放一个本地 blob + 一行 files,再按 body 建一篇引用它的短文(body 为空表示不引用)。
|
|
func seed(t *testing.T, a *API, key, body string) model.File {
|
|
t.Helper()
|
|
if err := a.Blobs.Put(t.Context(), key, strings.NewReader("pngbytes"), 8, "image/png"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err := a.Store.CreateFile(model.File{
|
|
Key: key, Name: filepath.Base(key), Mime: "image/png",
|
|
Size: 8, SHA256: strings.Repeat("f", 64), Store: "local",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body != "" {
|
|
if _, err := a.Store.Create(model.PostInput{
|
|
Kind: model.KindShort, Slug: "s-" + key, ContentMd: body, Status: model.StatusPublished,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return f
|
|
}
|
|
|
|
func TestFileRefsEndpoint(t *testing.T) {
|
|
a, _ := newTestAPI(t)
|
|
a.Blobs = storage.NewLocal(t.TempDir())
|
|
f := seed(t, a, "2026/09/aaa.png", `看图 `)
|
|
// 顺手把站主头像也指到同一张图,refs 要把这一路也报出来
|
|
if err := a.Store.SetSetting("owner_avatar_key", f.Key); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rec := doAs(t, a, http.MethodGet, "/api/admin/files/"+itoa(f.ID)+"/refs", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var v struct {
|
|
Key string `json:"key"`
|
|
Count int `json:"count"`
|
|
Items []model.FileRef `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v.Key != f.Key || v.Count != 2 || len(v.Items) != 2 {
|
|
t.Fatalf("refs 不对: %+v", v)
|
|
}
|
|
var kinds = map[string]bool{}
|
|
for _, x := range v.Items {
|
|
kinds[x.Kind] = true
|
|
}
|
|
if !kinds["post"] || !kinds["avatar"] {
|
|
t.Fatalf("缺引用类型: %+v", v.Items)
|
|
}
|
|
|
|
// 不存在的文件:404 而不是空列表
|
|
rec = doAs(t, a, http.MethodGet, "/api/admin/files/9999/refs", "")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("want 404, got %d", rec.Code)
|
|
}
|
|
// 乱七八糟的子路径不收
|
|
rec = doAs(t, a, http.MethodGet, "/api/admin/files/1/nope", "")
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("want 400, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// 有引用时默认挡住,而且挡住之后 blob 和行都得还在(可重试)。
|
|
func TestFileDeleteBlockedByRefs(t *testing.T) {
|
|
a, _ := newTestAPI(t)
|
|
dir := t.TempDir()
|
|
a.Blobs = storage.NewLocal(dir)
|
|
f := seed(t, a, "2026/09/bbb.png", ``)
|
|
|
|
rec := doAs(t, a, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("want 409, got %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var e struct {
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(e.Error, "1 处引用") {
|
|
t.Fatalf("错误消息没报引用数: %q", e.Error)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "2026/09/bbb.png")); err != nil {
|
|
t.Fatalf("被挡住时不该动存储: %v", err)
|
|
}
|
|
if _, err := a.Store.GetFile(f.ID); err != nil {
|
|
t.Fatalf("被挡住时不该删行: %v", err)
|
|
}
|
|
}
|
|
|
|
// 明确带 force=1 才真删:行、blob 一起走。
|
|
func TestFileDeleteForceProceeds(t *testing.T) {
|
|
a, _ := newTestAPI(t)
|
|
dir := t.TempDir()
|
|
a.Blobs = storage.NewLocal(dir)
|
|
f := seed(t, a, "2026/09/ccc.png", ``)
|
|
|
|
rec := doAs(t, a, http.MethodDelete, "/api/admin/files/"+itoa(f.ID)+"?force=1", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, err := a.Store.GetFile(f.ID); !errors.Is(err, store.ErrNotFound) {
|
|
t.Fatalf("行应已删除, got %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "2026/09/ccc.png")); !os.IsNotExist(err) {
|
|
t.Fatalf("blob 应已删除, got %v", err)
|
|
}
|
|
}
|
|
|
|
// 没被引用的文件不用 force 也能删(守卫不能把所有删除都挡死)。
|
|
func TestFileDeleteUnreferenced(t *testing.T) {
|
|
a, _ := newTestAPI(t)
|
|
a.Blobs = storage.NewLocal(t.TempDir())
|
|
f := seed(t, a, "2026/09/ddd.png", "")
|
|
|
|
rec := doAs(t, a, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, err := a.Store.GetFile(f.ID); !errors.Is(err, store.ErrNotFound) {
|
|
t.Fatalf("行应已删除, got %v", err)
|
|
}
|
|
}
|