多用户与角色:owner / admin / reader 三级,用户管理页 + 密码上库
- 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)
This commit is contained in:
1 parent
e5dee4daf1
commit
4bb2ff4145
23 files changed
+917
-222
No files matched your search
@@ -11,28 +11,32 @@ import (
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
// doAs 带着有效后台会话发一个请求。
|
||||
func doAs(t *testing.T, h http.Handler, method, path string, body string) *httptest.ResponseRecorder {
|
||||
// doAs 带着有效后台会话(owner)发一个请求。会话 token 只带用户 ID,
|
||||
// 所以先确保 owner 行存在,再按真实 ID 签。
|
||||
func doAs(t *testing.T, a *API, method, path string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
owner, err := a.Store.EnsureOwner("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// 用与 newTestAPI 里 NewSessions 相同的 secret 签一个会话
|
||||
sess := NewSessions("test-secret", time.Hour)
|
||||
tok, _ := sess.Issue("admin")
|
||||
tok, _ := sess.Issue(owner.ID)
|
||||
req.AddCookie(&http.Cookie{Name: cookieName, Value: tok})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
a.Routes().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestAccountGET(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
if _, err := a.Store.EnsureOwner("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := doAs(t, h, http.MethodGet, "/api/admin/account", "")
|
||||
rec := doAs(t, a, http.MethodGet, "/api/admin/account", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -55,11 +59,11 @@ func TestAccountGET(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccountPATCHProfile(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
if _, err := a.Store.EnsureOwner("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"name":"麻衣","bio":"活着就是为了樱岛麻衣"}`)
|
||||
rec := doAs(t, a, http.MethodPatch, "/api/admin/account", `{"name":"麻衣","bio":"活着就是为了樱岛麻衣"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -81,12 +85,12 @@ func TestAccountPATCHProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccountPATCHAvatarKey(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
if _, err := a.Store.EnsureOwner("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 不存在的 key 必须拒:否则会存下一个永远解析不出的头像
|
||||
rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"2026/09/nope.png"}`)
|
||||
rec := doAs(t, a, http.MethodPatch, "/api/admin/account", `{"avatar_key":"2026/09/nope.png"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("不存在的 key: got %d, want 400", rec.Code)
|
||||
}
|
||||
@@ -98,7 +102,7 @@ func TestAccountPATCHAvatarKey(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+f.Key+`"}`)
|
||||
rec = doAs(t, a, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+f.Key+`"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("非图片: got %d, want 400", rec.Code)
|
||||
}
|
||||
@@ -110,7 +114,7 @@ func TestAccountPATCHAvatarKey(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+img.Key+`"}`)
|
||||
rec = doAs(t, a, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+img.Key+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("图片头像: got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -143,11 +147,11 @@ func TestAccountRejectsAnonymous(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccountUnbindUnknown(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
if _, err := a.Store.EnsureOwner("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := doAs(t, h, http.MethodDelete, "/api/admin/account/identities/github", "")
|
||||
rec := doAs(t, a, http.MethodDelete, "/api/admin/account/identities/github", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("没绑过还解绑: got %d, want 404", rec.Code)
|
||||
}
|
||||
@@ -155,11 +159,11 @@ func TestAccountUnbindUnknown(t *testing.T) {
|
||||
|
||||
// passkey 未配置时必须明确不可用,而不是假装成功
|
||||
func TestPasskeysUnavailableWhenNil(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
if a.Passkeys != nil {
|
||||
t.Skip("测试构造里不该有 Passkeys")
|
||||
}
|
||||
rec := doAs(t, h, http.MethodPost, "/api/admin/account/passkeys/begin", "")
|
||||
rec := doAs(t, a, http.MethodPost, "/api/admin/account/passkeys/begin", "")
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("got %d, want 503", rec.Code)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user