Files
ONE/backend/internal/admin/account_test.go
T
Sakurasan f1e639e0ba 账户中心:站主资料可编辑 + 身份绑定 + passkey 登录
后台新增 /admin/account 一页,四块:公开资料、密码、第三方账号、Passkey。

-  schema:users 加 role(默认 reader),新表 user_identities、passkeys。
  user_identities 上双 UNIQUE —— (provider, extern_uid) 防一个外部账号顶两个身份,
  (user_id, provider) 防一站主绑两个同平台号,绑错也劫持不了。
  extern_uid 存平台稳定 ID,不存用户名(用户名可改)。
- 头像存 files 里的 key 而非 URL,换存储/CDN 不失效;单 key SetSetting 写入,
  避开 UpdateSettings 的整表替换会把它抹掉。站主名/简介从设置页挪到账户页,
  一个字段只留一个编辑入口。
- OAuth 绑定要求先有后台会话(绑定动作本身是提权路径);已绑的站主身份登录后
  直接发 one_session,读者身份仍发 one_reader。
- passkey 走 go-webauthn v0.15.0(最后一条吃 go 1.24 的版本线),可发现凭据登录。
  必须显式设 ONE_WEBAUTHN_ORIGINS 才启用,不配就安静关掉。
  签名计数只记克隆警告、不硬拦 —— 云同步 passkey 的计数本就不单调。
- 密码故意留在 ONE_ADMIN_PASSWORD,不做哈希入库:这是「解绑一切、删光 passkey
  也还能进门」的保底,比 env 明文更值得守。memos 那个 SSO 建号随机密码无重置
  入口的坑,从设计上绕开。

已知限制:会话仍是有状态无关的 HMAC cookie,删 passkey / 解绑不会让已发出的
7 天后台会话失效 —— 要修得加一张吊销表。
2026-09-30 01:08:55 +08:00

167 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package admin
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"oneblog/internal/model"
)
// doAs 带着有效后台会话发一个请求。
func doAs(t *testing.T, h http.Handler, method, path string, body string) *httptest.ResponseRecorder {
t.Helper()
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")
req.AddCookie(&http.Cookie{Name: cookieName, Value: tok})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestAccountGET(t *testing.T) {
a, h := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, http.MethodGet, "/api/admin/account", "")
if rec.Code != http.StatusOK {
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
}
var v struct {
Name string `json:"name"`
Handle string `json:"handle"`
Password struct{} `json:"password"`
Providers []string `json:"providers"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatal(err)
}
if v.Handle != "admin" {
t.Fatalf("handle=%q", v.Handle)
}
// 测试配置里没有 OAuth 凭据,可绑平台应为空
if len(v.Providers) != 0 {
t.Fatalf("providers=%v, want empty", v.Providers)
}
}
func TestAccountPATCHProfile(t *testing.T) {
a, h := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"name":"麻衣","bio":"活着就是为了樱岛麻衣"}`)
if rec.Code != http.StatusOK {
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
}
// 昵称要同时落在站主行与 settings(前端各处仍读 settings.author_name)
owner, err := a.Store.GetOwner()
if err != nil {
t.Fatal(err)
}
if owner.Name != "麻衣" {
t.Fatalf("owner.name=%q", owner.Name)
}
st, err := a.Store.GetSettings()
if err != nil {
t.Fatal(err)
}
if st.AuthorName != "麻衣" || st.AuthorBio != "活着就是为了樱岛麻衣" {
t.Fatalf("settings 未同步: %+v", st)
}
}
func TestAccountPATCHAvatarKey(t *testing.T) {
a, h := 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"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("不存在的 key: got %d, want 400", rec.Code)
}
// 真实存在但不是图片的也要拒
f, err := a.Store.CreateFile(model.File{
Key: "2026/09/notes.txt", Name: "notes.txt", Mime: "text/plain",
Size: 4, SHA256: strings.Repeat("a", 64), Store: "local",
})
if err != nil {
t.Fatal(err)
}
rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+f.Key+`"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("非图片: got %d, want 400", rec.Code)
}
// 图片就放行,并且单独写 owner_avatar_key(绕开 UpdateSettings 全量替换)
img, err := a.Store.CreateFile(model.File{
Key: "2026/09/me.png", Name: "me.png", Mime: "image/png",
Size: 4, SHA256: strings.Repeat("b", 64), Store: "local",
})
if err != nil {
t.Fatal(err)
}
rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+img.Key+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("图片头像: got %d %s", rec.Code, rec.Body.String())
}
st, _ := a.Store.GetSettings()
if st.AuthorAvatarKey != img.Key {
t.Fatalf("avatar key=%q", st.AuthorAvatarKey)
}
if !strings.Contains(rec.Body.String(), "/uploads/"+img.Key) {
t.Fatalf("响应里没解析出头像 URL: %s", rec.Body.String())
}
// 站点设置的整体 PUT 不该把头像键冲掉(两者写入路径分开)
if err := a.Store.UpdateSettings(st); err != nil {
t.Fatal(err)
}
after, _ := a.Store.GetSettings()
if after.AuthorAvatarKey != img.Key {
t.Fatalf("UpdateSettings 把头像键清了: %q", after.AuthorAvatarKey)
}
}
func TestAccountRejectsAnonymous(t *testing.T) {
_, h := newTestAPI(t)
for _, path := range []string{"/api/admin/account", "/api/admin/account/passkeys"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s: got %d, want 401", path, rec.Code)
}
}
}
func TestAccountUnbindUnknown(t *testing.T) {
a, h := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, http.MethodDelete, "/api/admin/account/identities/github", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("没绑过还解绑: got %d, want 404", rec.Code)
}
}
// passkey 未配置时必须明确不可用,而不是假装成功
func TestPasskeysUnavailableWhenNil(t *testing.T) {
a, h := newTestAPI(t)
if a.Passkeys != nil {
t.Skip("测试构造里不该有 Passkeys")
}
rec := doAs(t, h, http.MethodPost, "/api/admin/account/passkeys/begin", "")
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("got %d, want 503", rec.Code)
}
}