Files
ONE/backend/internal/admin/api_test.go
T
Sakurasan dd2994189a 安全加固 + 结构清理:修注入/串写/竞态,DOMPurify 上线,后端补事务与 handler 测试
后端:
- ORDER BY 白名单(sanitizeOrder)堵住 ?order= SQL 注入,补回归测试
- 登录限速(每 IP 10 次失败/10 分钟 429)、TLS/反代下 Secure cookie、NewAPI 构造器
- Delete/setTags/MergeTags/DeleteTag 包事务;Archive 去 500 篇上限
- 列表接口裁剪:不传 content_md,长文 content_html 截 600,新增 content_len;health 探 DB

前端:
- EditorView 路由复用串写修复(RouterView :key + sync watch 回写原文章)
- v-html 出口统一过 DOMPurify(sanitizeHtml),stripTags 改 DOMParser
- 列表竞态防护(Home/Tag/Posts 请求序号)、TagView 分页修复
- 侧栏接口 30s 缓存去重;one:unauthorized 监听器泄漏修复
- 删 styles.css 498 行重复块;移除 tailwind/marked/vue-tsc 死依赖;CommandPalette a11y 语义
2026-09-21 23:59:09 +08:00

144 lines
4.4 KiB
Go

package admin
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"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/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)
}
}