Files
ONE/backend/internal/admin/api_test.go
T
Sakurasan e68400b389 系统设置迁入后台:站点地址 / OAuth 凭据 / R2 存储 / 管理员账号,保存即生效无需重启
- 生效规则统一为「后台填了用后台的,没填回落环境变量」,老部署不改 env 照常跑
- config.Resolver:短缓存解析有效配置,存储后端按配置签名热重建;后台保存主动失效
- 秘密项(client secret / bot token / R2 密钥 / 管理员密码)接口永不回显明文,
  只报「是否已配置、来自哪里」;留空保存 = 保持现值
- 管理员密码 bcrypt 入库,DB 哈希优先、显式设置的 env 密码作解锁后路;
  后台改过密码后 admin/admin 开发模式立即失效
- 设置页新增「登录与存储」标签,基础信息加站点地址;秘密项带来源提示
- 监听地址 / 数据库 / 目录 / ONE_SECRET / Passkey 仍留环境变量(启动期依赖)
2026-10-01 12:14:10 +08:00

327 lines
11 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 明文
rec := put(`{"site_url":"https://db.example","github_client_id":"db-id",
"secrets":{"github_client_secret":"db-sec","admin_password":"newpass1"}}`)
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") || strings.Contains(rec.Body.String(), "newpass1") {
t.Fatal("secret echoed back in plaintext")
}
// 来源标记:site_url 来自 db,client_id 来自 db,secret 来自 db;
// 未写的项回落 env
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)
}
// DB 密码立即生效;显式设置的 env 密码仍作后路;错误的都不行
if _, ok := a.verifyAdmin("admin", "newpass1"); !ok {
t.Error("db password should work right after save")
}
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")
}
// admin_password 6 位下限
if rec := put(`{"secrets":{"admin_password":"123"}}`); rec.Code != http.StatusBadRequest {
t.Errorf("short password: got %d, want 400", rec.Code)
}
}
// InsecureDev(env 未显式设密码)时 admin/admin 有效,但后台一旦改了密码
// 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")
}
h := bcryptHash(t, "newpass1")
if err := a.Store.SetSetting("admin_password_hash", h); err != nil {
t.Fatal(err)
}
if _, ok := a.verifyAdmin("admin", "admin"); ok {
t.Error("admin/admin must stop working once a DB password exists")
}
if _, ok := a.verifyAdmin("admin", "newpass1"); !ok {
t.Error("db 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)
}