Files
ONE/backend/internal/admin/api_test.go
T
Sakurasan 4bb2ff4145 多用户与角色: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)
2026-10-01 22:35:37 +08:00

456 lines
16 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"
"golang.org/x/crypto/bcrypt"
"oneblog/internal/config"
"oneblog/internal/db"
"oneblog/internal/model"
"oneblog/internal/store"
)
func newTestAPI(t *testing.T) (*API, http.Handler) {
t.Helper()
d, err := db.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { d.Close() })
st, err := store.New(d)
if err != nil {
t.Fatalf("store: %v", err)
}
cfg := &config.Config{AdminUser: "admin", AdminPass: "s3cret"}
a := NewAPI(st, cfg, NewSessions("test-secret", time.Hour))
return a, a.Routes()
}
func login(t *testing.T, h http.Handler, user, pass string) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(loginRequest{Username: user, Password: pass})
req := httptest.NewRequest(http.MethodPost, "/api/admin/login", strings.NewReader(string(body)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestGuardRejectsAnonymous(t *testing.T) {
_, h := newTestAPI(t)
for _, path := range []string{"/api/admin/posts", "/api/admin/projects", "/api/admin/settings", "/api/admin/dashboard"} {
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 TestLoginIssuesUsableSession(t *testing.T) {
_, h := newTestAPI(t)
rec := login(t, h, "admin", "wrong")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong password: got %d, want 401", rec.Code)
}
rec = login(t, h, "admin", "s3cret")
if rec.Code != http.StatusOK {
t.Fatalf("login: got %d, want 200", rec.Code)
}
var out struct {
Token string `json:"token"`
}
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil || out.Token == "" {
t.Fatalf("no token in response: %v", err)
}
// Bearer 通道
req := httptest.NewRequest(http.MethodGet, "/api/admin/posts", nil)
req.Header.Set("Authorization", "Bearer "+out.Token)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req)
if rec2.Code != http.StatusOK {
t.Fatalf("bearer posts: got %d, want 200", rec2.Code)
}
// cookie 通道
req = httptest.NewRequest(http.MethodGet, "/api/admin/posts", nil)
req.AddCookie(&http.Cookie{Name: "one_session", Value: out.Token})
rec3 := httptest.NewRecorder()
h.ServeHTTP(rec3, req)
if rec3.Code != http.StatusOK {
t.Fatalf("cookie posts: got %d, want 200", rec3.Code)
}
}
func TestLoginRateLimitedAfterRepeatedFailures(t *testing.T) {
_, h := newTestAPI(t)
var rec *httptest.ResponseRecorder
for i := 0; i < maxLoginFails+1; i++ {
rec = login(t, h, "admin", "bad")
}
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("after %d failures: got %d, want 429", maxLoginFails+1, rec.Code)
}
// 限速期间即使口令正确也被拒
rec = login(t, h, "admin", "s3cret")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("locked out login: got %d, want 429", rec.Code)
}
}
func TestSecureCookieBehindTLSProxy(t *testing.T) {
_, h := newTestAPI(t)
body, _ := json.Marshal(loginRequest{Username: "admin", Password: "s3cret"})
req := httptest.NewRequest(http.MethodPost, "/api/admin/login", strings.NewReader(string(body)))
req.Header.Set("X-Forwarded-Proto", "https")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login: %d", rec.Code)
}
cookie := rec.Header().Get("Set-Cookie")
if !strings.Contains(cookie, "Secure") {
t.Fatalf("expected Secure flag over https proxy, got: %s", cookie)
}
}
// ?order= 直接来自查询串并拼进 SQL,注入串必须被白名单挡掉且不影响数据。
func TestOrderParamInjectionIsNeutralized(t *testing.T) {
a, h := newTestAPI(t)
if _, err := a.Store.Create(model.PostInput{Title: "one", ContentMd: "a", Status: model.StatusPublished}); err != nil {
t.Fatal(err)
}
token := login(t, h, "admin", "s3cret")
var out struct {
Token string `json:"token"`
}
_ = json.NewDecoder(token.Body).Decode(&out)
req := httptest.NewRequest(http.MethodGet, "/api/admin/posts?order="+
strings.ReplaceAll("published_at DESC; DROP TABLE posts; --", " ", "%20"), nil)
req.Header.Set("Authorization", "Bearer "+out.Token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("injected order: got %d body=%s", rec.Code, rec.Body.String())
}
// 表若被 DROP,这条查询会报错
if _, err := a.Store.List(store.ListOptions{Status: "any"}); err != nil {
t.Fatalf("posts table damaged: %v", err)
}
}
// 设置里的 ui_id / custom_css 必须能经 PUT→GET 往返,且非法分区被挡掉。
func TestSettingsUIRoundTrip(t *testing.T) {
_, h := newTestAPI(t)
token := login(t, h, "admin", "s3cret")
var sess struct {
Token string `json:"token"`
}
_ = json.NewDecoder(token.Body).Decode(&sess)
if sess.Token == "" {
t.Fatal("login failed")
}
put := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+sess.Token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
rec := put(`{"site_title":"ONE","posts_per_page":10,"light_skin_id":"paper",
"ui_id":"vivid","custom_css":{"home":"body{--v-accent:#f0f}","bogus":"x{}"}}`)
if rec.Code != http.StatusOK {
t.Fatalf("put settings: got %d body=%s", rec.Code, rec.Body.String())
}
get := httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil)
get.Header.Set("Authorization", "Bearer "+sess.Token)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, get)
if rec.Code != http.StatusOK {
t.Fatalf("get settings: got %d", rec.Code)
}
var wrap struct {
Settings model.Settings `json:"settings"`
CredentialMeta map[string]any `json:"credential_meta"`
Secrets map[string]string `json:"-"`
}
if err := json.NewDecoder(rec.Body).Decode(&wrap); err != nil {
t.Fatalf("decode: %v", err)
}
got := wrap.Settings
if got.UIID != "vivid" {
t.Errorf("ui_id = %q, want vivid", got.UIID)
}
if got.CustomCSS["home"] != "body{--v-accent:#f0f}" {
t.Errorf("home css lost: %+v", got.CustomCSS)
}
if _, ok := got.CustomCSS["bogus"]; ok {
t.Error("unknown section should not be persisted")
}
// 秘密项在任何响应里都不该出现明文
if strings.Contains(rec.Body.String(), "client_secret\":") &&
!strings.Contains(rec.Body.String(), "credential_meta") {
t.Error("settings response should not carry raw secrets")
}
// An invalid ui_id falls back rather than being stored verbatim.
rec = put(`{"site_title":"ONE","posts_per_page":10,"light_skin_id":"paper","ui_id":"neon","custom_css":{}}`)
if rec.Code != http.StatusOK {
t.Fatalf("put invalid ui: got %d body=%s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, get)
_ = json.NewDecoder(rec.Body).Decode(&wrap)
got = wrap.Settings
if got.UIID != "classic" {
t.Errorf("invalid ui_id should fall back to classic, got %q", got.UIID)
}
if got.CustomCSS == nil {
t.Error("custom_css should be non-nil")
}
}
// 系统设置迁移项:secrets 走单独通道写入且不可读回;DB 值在有效配置里
// 覆盖环境变量;登录改用 DB 密码后 env 密码是否还作数取决于是否显式配置。
func TestSettingsCredentialsRoundTrip(t *testing.T) {
a, h := newTestAPI(t)
a.Cfg = &config.Config{SiteURL: "http://env.example", AdminUser: "admin",
AdminPass: "s3cret", GitHubClientID: "env-id", GitHubClientSecret: "env-sec",
GoogleClientID: "env-google"}
a.Res = config.NewResolver(a.Cfg, a.Store)
token := login(t, h, "admin", "s3cret")
var sess struct {
Token string `json:"token"`
}
_ = json.NewDecoder(token.Body).Decode(&sess)
put := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+sess.Token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// 写入 DB 值(含一个 secret);响应与 GET 都不能回显 secret 明文。
// 密码不再走 settings(多用户后在 users 行上),改走 account/password 端点。
rec := put(`{"site_url":"https://db.example","github_client_id":"db-id",
"secrets":{"github_client_secret":"db-sec"}}`)
if rec.Code != http.StatusOK {
t.Fatalf("put: got %d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "db-sec") {
t.Fatal("secret echoed back in plaintext")
}
// admin_password 已退役:出现即 400
if rec := put(`{"secrets":{"admin_password":"newpass1"}}`); rec.Code != http.StatusBadRequest {
t.Errorf("legacy admin_password: got %d, want 400", rec.Code)
}
// 来源标记:site_url 来自 db,client_id 来自 db,secret 来自 db;
// 未写的项回落 env;密码此时还在 env 引导链上(首次登录已自迁移成行哈希,
// 来源也是 db)
if meta, err := a.credentialMeta(); err != nil {
t.Fatal(err)
} else {
want := map[string]string{
"site_url": "db", "github_client_id": "db",
"github_client_secret": "db", "google_client_id": "env",
"admin_password_hash": "db",
}
for k, src := range want {
m, _ := meta[k].(map[string]any)
if m == nil || m["source"] != src {
t.Errorf("meta[%s] = %+v, want source=%s", k, m, src)
}
}
}
// 有效配置:DB 值覆盖 env;没配的项回落 env
c := a.cfg()
if c.SiteURL != "https://db.example" || c.GitHubClientID != "db-id" {
t.Errorf("overlay failed: site_url=%q client_id=%q", c.SiteURL, c.GitHubClientID)
}
if c.GitHubClientSecret != "db-sec" {
t.Errorf("secret overlay failed: %q", c.GitHubClientSecret)
}
// 自己改密码:旧密码错被拒,对了立即生效;显式 env 密码仍作后路
patch := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPatch, "/api/admin/account/password", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+sess.Token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
if rec := patch(`{"old_password":"wrong","new_password":"newpass1"}`); rec.Code != http.StatusForbidden {
t.Errorf("wrong old password: got %d, want 403", rec.Code)
}
if rec := patch(`{"old_password":"s3cret","new_password":"newpass1"}`); rec.Code != http.StatusOK {
t.Errorf("change password: got %d body=%s", rec.Code, rec.Body.String())
}
if _, ok := a.verifyAdmin("admin", "newpass1"); !ok {
t.Error("row password should work right after change")
}
if _, ok := a.verifyAdmin("admin", "s3cret"); !ok {
t.Error("explicit env password should stay as backstop")
}
if _, ok := a.verifyAdmin("admin", "wrong"); ok {
t.Error("wrong password must fail")
}
}
// InsecureDev(env 未显式设密码)时 admin/admin 有效,但后台一旦改了密码
// admin/admin 必须立刻失效。
// InsecureDev(env 未显式设密码)时 admin/admin 有效;首次登录会把引导凭据
// 自迁移成 owner 行哈希——之后站主改了密码(行哈希更新),admin/admin 即失效。
func TestInsecureDevDisabledByDBPassword(t *testing.T) {
a, _ := newTestAPI(t)
a.Cfg = &config.Config{AdminUser: "admin", AdminPass: "admin", InsecureDev: true}
if _, ok := a.verifyAdmin("admin", "admin"); !ok {
t.Fatal("insecure default should work before any password is set")
}
owner, err := a.Store.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
if owner.PasswordHash == "" {
t.Fatal("first login should migrate bootstrap credentials onto the owner row")
}
if err := a.Store.SetStaffPassword(owner.ID, bcryptHash(t, "newpass1")); err != nil {
t.Fatal(err)
}
if _, ok := a.verifyAdmin("admin", "admin"); ok {
t.Error("admin/admin must stop working once a row password exists")
}
if _, ok := a.verifyAdmin("admin", "newpass1"); !ok {
t.Error("row password should be accepted")
}
}
func bcryptHash(t *testing.T, pw string) string {
t.Helper()
b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// 多用户与角色:owner 创建 admin 账号;admin 能进内容接口,
// 动不了系统设置与用户管理;被停用后存量会话立即失效。
func TestMultiUserLifecycle(t *testing.T) {
_, h := newTestAPI(t)
tokOf := func(user, pass string) string {
rec := login(t, h, user, pass)
var out struct {
Token string `json:"token"`
Role string `json:"role"`
}
if rec.Code != http.StatusOK {
t.Fatalf("login %s: got %d body=%s", user, rec.Code, rec.Body.String())
}
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil || out.Token == "" {
t.Fatalf("login %s: no token: %v", user, err)
}
if user == "admin" && out.Role != model.RoleOwner {
t.Errorf("owner role = %q", out.Role)
}
return out.Token
}
reqAs := func(tok, method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+tok)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
owner := tokOf("admin", "s3cret")
// owner 创建内容管理员
rec := reqAs(owner, http.MethodPost, "/api/admin/users", `{"username":"editor","password":"editor1"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("create staff: got %d body=%s", rec.Code, rec.Body.String())
}
// 用户名占用
if rec := reqAs(owner, http.MethodPost, "/api/admin/users", `{"username":"editor","password":"editor1"}`); rec.Code != http.StatusConflict {
t.Errorf("duplicate username: got %d, want 409", rec.Code)
}
// staff 登录,角色是 admin
staff := tokOf("editor", "editor1")
// 内容接口可进
if rec := reqAs(staff, http.MethodGet, "/api/admin/posts", ""); rec.Code != http.StatusOK {
t.Errorf("staff read posts: got %d", rec.Code)
}
// 系统设置写不了、用户管理进不去
if rec := reqAs(staff, http.MethodPut, "/api/admin/settings", `{"site_title":"x"}`); rec.Code != http.StatusForbidden {
t.Errorf("staff put settings: got %d, want 403", rec.Code)
}
if rec := reqAs(staff, http.MethodGet, "/api/admin/users", ""); rec.Code != http.StatusForbidden {
t.Errorf("staff list users: got %d, want 403", rec.Code)
}
if rec := reqAs(staff, http.MethodPost, "/api/admin/users", `{"username":"x2","password":"xxxxxx"}`); rec.Code != http.StatusForbidden {
t.Errorf("staff create user: got %d, want 403", rec.Code)
}
// owner 重置 staff 密码后,新密码立即生效
staffID := 0
list := reqAs(owner, http.MethodGet, "/api/admin/users", "")
var lu struct {
Users []model.Reader `json:"users"`
}
_ = json.NewDecoder(list.Body).Decode(&lu)
for _, u := range lu.Users {
if u.Handle == "editor" {
staffID = int(u.ID)
if u.Role != model.RoleAdmin {
t.Errorf("staff role = %q, want admin", u.Role)
}
}
}
if staffID == 0 {
t.Fatal("editor not in staff list")
}
if rec := reqAs(owner, http.MethodPatch, "/api/admin/users/"+itoa(int64(staffID)),
`{"password":"reset99"}`); rec.Code != http.StatusOK {
t.Errorf("reset password: got %d", rec.Code)
}
if rec := login(t, h, "editor", "reset99"); rec.Code != http.StatusOK {
t.Errorf("login with reset password: got %d", rec.Code)
}
// 停用后存量会话立即 401;owner 行与自身不可停用 / 不可删
if rec := reqAs(owner, http.MethodPatch, "/api/admin/users/"+itoa(int64(staffID)), `{"banned":true}`); rec.Code != http.StatusOK {
t.Fatalf("ban staff: got %d", rec.Code)
}
if rec := reqAs(staff, http.MethodGet, "/api/admin/posts", ""); rec.Code != http.StatusUnauthorized {
t.Errorf("banned staff session: got %d, want 401", rec.Code)
}
if rec := login(t, h, "editor", "reset99"); rec.Code != http.StatusUnauthorized {
t.Errorf("banned staff login: got %d, want 401", rec.Code)
}
if rec := reqAs(owner, http.MethodDelete, "/api/admin/users/"+itoa(int64(staffID)), ""); rec.Code != http.StatusOK {
t.Errorf("delete staff: got %d", rec.Code)
}
// owner 行自我保护
if rec := reqAs(owner, http.MethodDelete, "/api/admin/users/1", ""); rec.Code == http.StatusOK {
t.Error("owner row must not be deletable")
}
}