Files
go_blog/handlers/p2_validation_test.go
T
kevin f307781f58 docs: 全部 Go 代码注释汉化
- 48 个 Go 文件所有注释(行注释/块注释/行尾注释,含 _test.go)翻译为中文
- 保留技术标识符:SECURITY_TODO(n)、unsafe-inline、sqlite/mysql、路由参数等
- 代码、字符串字面量、日志消息保持英文原文,零逻辑改动
- go build/vet 通过,go test -count=1 ./... 全绿
2026-08-27 19:03:03 +08:00

426 lines
14 KiB
Go

package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"go_blog/models"
)
// postForm 是表单编码请求的小助手,始终携带 CSRF 令牌。
func postForm(e *securityTestEnv, method, path, cookie, csrfToken string, fields url.Values) *httptest.ResponseRecorder {
if fields == nil {
fields = url.Values{}
}
if csrfToken != "" {
fields.Set("_csrf", csrfToken)
}
return e.do(method, path, cookie, strings.NewReader(fields.Encode()), "application/x-www-form-urlencoded")
}
// TestStorageDirTraversalRejected 覆盖 SECURITY_TODO #22:管理员不得将
// storage_dir 设置为逃逸出存储根目录的值。
func TestStorageDirTraversalRejected(t *testing.T) {
e := newSecurityTestEnv(t)
admin := e.login(t, "admin")
token := e.csrfTokenFor(t, admin)
cases := []string{
"../evil",
"foo/bar",
"a\\b",
"/abs/path",
"..",
".",
}
for _, dir := range cases {
fields := url.Values{}
fields.Set("action", "save_config")
fields.Set("storage_dir", dir)
w := postForm(e, http.MethodPost, "/admin/settings/upload", admin, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("storage_dir %q: status = %d, want 302", dir, w.Code)
}
if loc := w.Header().Get("Location"); !strings.Contains(loc, "illegal_dir") {
t.Fatalf("storage_dir %q: location = %q, want illegal_dir error", dir, loc)
}
// 存储的值必须保持不变。
var u models.UploadConfig
if err := e.db.First(&u, 1).Error; err != nil {
t.Fatalf("load upload config: %v", err)
}
if u.StorageDir != "attachments" {
t.Fatalf("storage_dir %q: persisted value = %q, want unchanged \"attachments\"", dir, u.StorageDir)
}
}
// 安全的单段值可被接受。
fields := url.Values{}
fields.Set("action", "save_config")
fields.Set("storage_dir", "my_attach-2")
w := postForm(e, http.MethodPost, "/admin/settings/upload", admin, token, fields)
if w.Code != http.StatusFound || strings.Contains(w.Header().Get("Location"), "illegal_dir") {
t.Fatalf("safe storage_dir: status = %d, location = %q", w.Code, w.Header().Get("Location"))
}
var u models.UploadConfig
if err := e.db.First(&u, 1).Error; err != nil {
t.Fatalf("load upload config: %v", err)
}
if u.StorageDir != "my_attach-2" {
t.Fatalf("persisted storage_dir = %q, want my_attach-2", u.StorageDir)
}
}
// TestProfilePasswordMinLength 覆盖个人资料密码修改路径上的
// SECURITY_TODO #23。
func TestProfilePasswordMinLength(t *testing.T) {
e := newSecurityTestEnv(t)
alice := e.login(t, "alice")
token := e.csrfTokenFor(t, alice)
// 1 个字符的密码必须被拒绝,旧哈希保持不变。
fields := url.Values{}
fields.Set("current_password", "pw-alice")
fields.Set("new_password", "a")
w := postForm(e, http.MethodPost, "/profile", alice, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("short password: status = %d, want 302", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/profile?error=pw_short" {
t.Fatalf("short password: location = %q, want /profile?error=pw_short", loc)
}
var u models.User
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
t.Fatalf("load alice: %v", err)
}
if !u.CheckPassword("pw-alice") {
t.Fatal("old password no longer verifies after rejected change")
}
// 6 个字符的密码可被接受。
fields = url.Values{}
fields.Set("current_password", "pw-alice")
fields.Set("new_password", "newpass6")
w = postForm(e, http.MethodPost, "/profile", alice, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("valid password change: status = %d", w.Code)
}
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
t.Fatalf("reload alice: %v", err)
}
if !u.CheckPassword("newpass6") || u.CheckPassword("pw-alice") {
t.Fatal("password change did not take effect")
}
}
// TestProfileEmailValidation 覆盖个人资料路径上的 SECURITY_TODO #24。
func TestProfileEmailValidation(t *testing.T) {
e := newSecurityTestEnv(t)
alice := e.login(t, "alice")
token := e.csrfTokenFor(t, alice)
fields := url.Values{}
fields.Set("email", "not-an-email")
w := postForm(e, http.MethodPost, "/profile", alice, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("invalid email: status = %d, want 302", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/profile?error=email" {
t.Fatalf("invalid email: location = %q, want /profile?error=email", loc)
}
var u models.User
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
t.Fatalf("load alice: %v", err)
}
if u.Email != "" {
t.Fatalf("invalid email was persisted: %q", u.Email)
}
fields = url.Values{}
fields.Set("email", "alice@example.com")
w = postForm(e, http.MethodPost, "/profile", alice, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("valid email: status = %d, want 302", w.Code)
}
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
t.Fatalf("reload alice: %v", err)
}
if u.Email != "alice@example.com" {
t.Fatalf("valid email not persisted: %q", u.Email)
}
}
// TestAdminUserPasswordAndEmailEnforcement 覆盖后台用户创建/更新路径上的
// SECURITY_TODO #23/#24。
func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) {
e := newSecurityTestEnv(t)
admin := e.login(t, "admin")
token := e.csrfTokenFor(t, admin)
// 创建:短密码被拒绝(不创建任何行)。
fields := url.Values{}
fields.Set("username", "charlie")
fields.Set("password", "ab")
fields.Set("role", models.RoleAuthor)
w := postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
if w.Code != http.StatusOK {
t.Fatalf("create with short password: status = %d, want 200 (re-render)", w.Code)
}
if !strings.Contains(w.Body.String(), "6 characters") {
t.Fatal("short-password error message not rendered")
}
// 创建:非法邮箱被拒绝。
fields = url.Values{}
fields.Set("username", "charlie")
fields.Set("password", "longenough")
fields.Set("email", "abc")
fields.Set("role", models.RoleAuthor)
w = postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
if w.Code != http.StatusOK {
t.Fatalf("create with invalid email: status = %d, want 200", w.Code)
}
var count int64
e.db.Model(&models.User{}).Where("username = ?", "charlie").Count(&count)
if count != 0 {
t.Fatal("charlie was created despite invalid input")
}
// 创建:合法数据成功。
fields = url.Values{}
fields.Set("username", "charlie")
fields.Set("password", "longenough")
fields.Set("email", "charlie@example.com")
fields.Set("role", models.RoleAuthor)
w = postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("create valid user: status = %d", w.Code)
}
e.db.Model(&models.User{}).Where("username = ?", "charlie").Count(&count)
if count != 1 {
t.Fatal("charlie was not created")
}
// 更新(密码重置路径):短密码被拒绝,哈希保持不变。
aliceID := userIDByUsername(t, e.db, "alice")
fields = url.Values{}
fields.Set("password", "x")
fields.Set("role", models.RoleAuthor)
w = postForm(e, http.MethodPost, fmt.Sprintf("/admin/users/%d/edit", aliceID), admin, token, fields)
if w.Code != http.StatusOK {
t.Fatalf("update with short password: status = %d, want 200", w.Code)
}
var u models.User
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
t.Fatalf("load alice: %v", err)
}
if !u.CheckPassword("pw-alice") {
t.Fatal("alice password changed by a rejected reset")
}
// 更新:非法邮箱被拒绝,旧值保留。
fields = url.Values{}
fields.Set("email", "bad")
fields.Set("role", models.RoleAuthor)
w = postForm(e, http.MethodPost, fmt.Sprintf("/admin/users/%d/edit", aliceID), admin, token, fields)
if w.Code != http.StatusOK {
t.Fatalf("update with invalid email: status = %d, want 200", w.Code)
}
e.db.Where("username = ?", "alice").First(&u)
if u.Email != "" {
t.Fatalf("invalid admin-set email persisted: %q", u.Email)
}
}
// TestRegisterRejectsInvalidEmail 覆盖注册上的 SECURITY_TODO #24。
func TestRegisterRejectsInvalidEmail(t *testing.T) {
e := newSecurityTestEnv(t)
if err := e.db.Model(&models.SiteSetting{}).Where("id = ?", 1).Update("allow_registration", true).Error; err != nil {
t.Fatalf("enable registration: %v", err)
}
// 获取注册表单以获得匿名 CSRF 令牌 + 会话。
req := httptest.NewRequest(http.MethodGet, "/register", nil)
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /register: status = %d", w.Code)
}
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
if m == nil {
t.Fatal("register page did not render a CSRF token")
}
anonCookie := e.sessionCookie(w)
fields := url.Values{}
fields.Set("username", "carol")
fields.Set("password", "secret1")
fields.Set("confirm_password", "secret1")
fields.Set("email", "abc")
w2 := postForm(e, http.MethodPost, "/register", anonCookie, m[1], fields)
if w2.Code != http.StatusFound {
t.Fatalf("register invalid email: status = %d, want 302", w2.Code)
}
if loc := w2.Header().Get("Location"); loc != "/register?error=register_email_invalid" {
t.Fatalf("register invalid email: location = %q", loc)
}
var count int64
e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count)
if count != 0 {
t.Fatal("carol created with invalid email")
}
fields.Set("email", "carol@example.com")
w2 = postForm(e, http.MethodPost, "/register", anonCookie, m[1], fields)
if w2.Code != http.StatusFound {
t.Fatalf("register valid email: status = %d, want 302", w2.Code)
}
e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count)
if count != 1 {
t.Fatal("carol not created")
}
}
// limiterEntryKey 通过扫描已跟踪条目返回某用户名的限流器键
// (前缀的客户端 IP 取决于测试传输方式)。
func limiterEntryKey(e *securityTestEnv, username string) string {
for k := range e.limiter.entries {
if strings.HasSuffix(k, "\x00"+username) {
return k
}
}
return ""
}
// TestLoginRateLimited 覆盖 SECURITY_TODO #10:重复失败会锁定
// IP+用户名键,成功登录后重置。
func TestLoginRateLimited(t *testing.T) {
e := newSecurityTestEnv(t)
loginAttempt := func(form url.Values) (*httptest.ResponseRecorder, string) {
// 每次尝试使用全新的匿名会话(和 CSRF 令牌)。
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
if m == nil {
t.Fatal("login page did not render a CSRF token")
}
anonCookie := e.sessionCookie(w)
form.Set("_csrf", m[1])
w2 := postForm(e, http.MethodPost, "/login", anonCookie, m[1], form)
return w2, anonCookie
}
bad := url.Values{"username": {"alice"}, "password": {"wrong-password"}}
for i := 0; i < maxLoginFailures; i++ {
w, _ := loginAttempt(bad)
if w.Code != http.StatusFound {
t.Fatalf("attempt %d: status = %d, want 302", i+1, w.Code)
}
if loc := w.Header().Get("Location"); loc != "/login?error=1" {
t.Fatalf("attempt %d: location = %q, want /login?error=1", i+1, loc)
}
}
// 下一次尝试(即使密码正确)也会被锁定。
good := url.Values{"username": {"alice"}, "password": {"pw-alice"}}
w, _ := loginAttempt(good)
if w.Code != http.StatusFound {
t.Fatalf("locked attempt: status = %d, want 302", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/login?error=locked" {
t.Fatalf("locked attempt: location = %q, want /login?error=locked", loc)
}
// 不同的键(用户名)不受影响。
w, _ = loginAttempt(url.Values{"username": {"bob"}, "password": {"pw-bob"}})
if w.Code != http.StatusFound || w.Header().Get("Location") != "/" {
t.Fatalf("different user login during lock: status = %d, location = %q",
w.Code, w.Header().Get("Location"))
}
// 重置后,被锁定的键再次可用。
aliceKey := limiterEntryKey(e, "alice")
if aliceKey == "" {
t.Fatal("alice rate-limit entry not found")
}
e.limiter.Reset(aliceKey)
w, _ = loginAttempt(good)
if w.Code != http.StatusFound || w.Header().Get("Location") != "/" {
t.Fatalf("login after reset: status = %d, location = %q",
w.Code, w.Header().Get("Location"))
}
}
// TestLoginTimingDoesNotRevealUser 断言 SECURITY_TODO #25 的结构性保证:
// 未知用户名仍执行一次 bcrypt 比较(虚拟哈希)并记录一次失败,
// 因此两个分支在设计上耗时不可区分。
func TestLoginTimingDoesNotRevealUser(t *testing.T) {
e := newSecurityTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
if m == nil {
t.Fatal("login page did not render a CSRF token")
}
anonCookie := e.sessionCookie(w)
fields := url.Values{"username": {"does-not-exist-31415"}, "password": {"anything"}}
fields.Set("_csrf", m[1])
w2 := postForm(e, http.MethodPost, "/login", anonCookie, m[1], fields)
if w2.Code != http.StatusFound || w2.Header().Get("Location") != "/login?error=1" {
t.Fatalf("unknown user: status = %d, location = %q", w2.Code, w2.Header().Get("Location"))
}
// 未知用户的键必须被计入失败次数(若限流器共享),
// 证明该分支走过了 Fail + 虚拟 bcrypt 路径。
if key := limiterEntryKey(e, "does-not-exist-31415"); key == "" {
t.Fatal("unknown-user branch did not record a failure")
} else if e.limiter.entries[key].failures != 1 {
t.Fatalf("unknown-user failure count = %d, want 1", e.limiter.entries[key].failures)
}
}
func TestSafeStorageDirNameAndValidators(t *testing.T) {
for _, tc := range []struct {
dir string
ok bool
}{
{"attachments", true},
{"my_attach-2", true},
{"A1-_", true},
{"", false},
{"../evil", false},
{"foo/bar", false},
{"a\\b", false},
{"/abs", false},
{"..", false},
{"a b", false},
{".hidden", false},
} {
if got := safeStorageDirName(tc.dir); got != tc.ok {
t.Errorf("safeStorageDirName(%q) = %v, want %v", tc.dir, got, tc.ok)
}
}
if validatePassword("12345") {
t.Error("validatePassword accepted 5 chars")
}
if !validatePassword("123456") {
t.Error("validatePassword rejected 6 chars")
}
if !validateEmail("") || !validateEmail("user@example.com") {
t.Error("validateEmail rejected empty or valid address")
}
if validateEmail("abc") || validateEmail("a@b@c") {
t.Error("validateEmail accepted malformed address")
}
}