Files
go_blog/handlers/p2_validation_test.go
T
kevin 76b744d5b5 feat: 个人资料接口 JSON 化——POST /api/profile
- profile.go:UpdateProfile 改 JSON 绑定(profileRequest),移除内嵌
  头像文件分支(头像统一走 /api/profile/avatar,XSS 链校验保留在
  UploadAvatar);错误改 APIError(400 profile_wrong_password/
  profile_password_short/profile_email_invalid、404 user_not_found、
  500),成功 {ok,redirect:/profile?saved=1}
- main.go:/profile 组旧 POST 移除,/api/profile[/avatar] 分组收敛
- profile.html:主表单改 blogAPI(头像 cropper 流程独立不变),
  新增 profileError 错误区
- 测试:TestProfilePasswordMinLength/TestProfileEmailValidation 改
  JSON(400+code);TestUpdateProfileAvatarRejectsNonImage 改打
  /api/profile/avatar;env 路由同步 /api/profile
- go build/vet/test 全绿
2026-08-27 19:56:03 +08:00

394 lines
14 KiB
Go

package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
"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 {
w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token,
gin.H{"action": "save_config", "storage_dir": dir})
if w.Code != http.StatusBadRequest || respCode(w) != "settings_upload_illegal_dir" {
t.Fatalf("storage_dir %q: status = %d, code = %q, want 400/settings_upload_illegal_dir",
dir, w.Code, respCode(w))
}
// 存储的值必须保持不变。
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)
}
}
// 安全的单段值可被接受。
w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token,
gin.H{"action": "save_config", "storage_dir": "my_attach-2"})
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("safe storage_dir: status = %d, body %s", w.Code, w.Body.String())
}
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 个字符的密码必须被拒绝,旧哈希保持不变。
w := postJSON(e, http.MethodPost, "/api/profile", alice, token,
gin.H{"current_password": "pw-alice", "new_password": "a"})
if w.Code != http.StatusBadRequest || respCode(w) != "profile_password_short" {
t.Fatalf("short password: status = %d, code = %q, want 400/profile_password_short",
w.Code, respCode(w))
}
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 个字符的密码可被接受。
w = postJSON(e, http.MethodPost, "/api/profile", alice, token,
gin.H{"current_password": "pw-alice", "new_password": "newpass6"})
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("valid password change: status = %d, body %s", w.Code, w.Body.String())
}
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)
w := postJSON(e, http.MethodPost, "/api/profile", alice, token,
gin.H{"email": "not-an-email"})
if w.Code != http.StatusBadRequest || respCode(w) != "profile_email_invalid" {
t.Fatalf("invalid email: status = %d, code = %q", w.Code, respCode(w))
}
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)
}
w = postJSON(e, http.MethodPost, "/api/profile", alice, token,
gin.H{"email": "alice@example.com"})
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("valid email: status = %d, body %s", w.Code, w.Body.String())
}
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)
// 创建:短密码被拒绝(不创建任何行)。
create := gin.H{"username": "charlie", "password": "ab", "role": models.RoleAuthor}
w := postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create)
if w.Code != http.StatusBadRequest || respCode(w) != "user_password_short" {
t.Fatalf("create with short password: status = %d, code = %q, want 400/user_password_short",
w.Code, respCode(w))
}
// 创建:非法邮箱被拒绝。
create["password"] = "longenough"
create["email"] = "abc"
w = postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create)
if w.Code != http.StatusBadRequest || respCode(w) != "user_email_invalid" {
t.Fatalf("create with invalid email: status = %d, code = %q", w.Code, respCode(w))
}
var count int64
e.db.Model(&models.User{}).Where("username = ?", "charlie").Count(&count)
if count != 0 {
t.Fatal("charlie was created despite invalid input")
}
// 创建:合法数据成功。
create["email"] = "charlie@example.com"
w = postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create)
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("create valid user: status = %d, body %s", w.Code, w.Body.String())
}
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")
w = postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", aliceID), admin, token,
gin.H{"password": "x", "role": models.RoleAuthor})
if w.Code != http.StatusBadRequest || respCode(w) != "user_password_short" {
t.Fatalf("update with short password: status = %d, code = %q", w.Code, respCode(w))
}
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")
}
// 更新:非法邮箱被拒绝,旧值保留。
w = postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", aliceID), admin, token,
gin.H{"email": "bad", "role": models.RoleAuthor})
if w.Code != http.StatusBadRequest || respCode(w) != "user_email_invalid" {
t.Fatalf("update with invalid email: status = %d, code = %q", w.Code, respCode(w))
}
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 := gin.H{
"username": "carol",
"password": "secret1",
"confirm_password": "secret1",
"email": "abc",
}
w2 := postJSON(e, http.MethodPost, "/api/auth/register", anonCookie, m[1], fields)
if w2.Code != http.StatusBadRequest {
t.Fatalf("register invalid email: status = %d, want 400", w2.Code)
}
if code := respCode(w2); code != "register_email_invalid" {
t.Fatalf("register invalid email: code = %q, want register_email_invalid", code)
}
var count int64
e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count)
if count != 0 {
t.Fatal("carol created with invalid email")
}
fields["email"] = "carol@example.com"
w2 = postJSON(e, http.MethodPost, "/api/auth/register", anonCookie, m[1], fields)
if w2.Code != http.StatusOK || !respOK(w2) {
t.Fatalf("register valid email: status = %d, body %s", w2.Code, w2.Body.String())
}
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(body gin.H) (*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)
w2 := postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, m[1], body)
return w2, anonCookie
}
bad := gin.H{"username": "alice", "password": "wrong-password"}
for i := 0; i < maxLoginFailures; i++ {
w, _ := loginAttempt(bad)
if w.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code)
}
if code := respCode(w); code != "login_error" {
t.Fatalf("attempt %d: code = %q, want login_error", i+1, code)
}
}
// 下一次尝试(即使密码正确)也会被锁定。
good := gin.H{"username": "alice", "password": "pw-alice"}
w, _ := loginAttempt(good)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("locked attempt: status = %d, want 429", w.Code)
}
if code := respCode(w); code != "login_locked" {
t.Fatalf("locked attempt: code = %q, want login_locked", code)
}
// 不同的键(用户名)不受影响。
w, _ = loginAttempt(gin.H{"username": "bob", "password": "pw-bob"})
if w.Code != http.StatusOK || respRedirect(w) != "/" {
t.Fatalf("different user login during lock: status = %d, redirect = %q",
w.Code, respRedirect(w))
}
// 重置后,被锁定的键再次可用。
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.StatusOK || respRedirect(w) != "/" {
t.Fatalf("login after reset: status = %d, redirect = %q",
w.Code, respRedirect(w))
}
}
// 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)
w2 := postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, m[1],
gin.H{"username": "does-not-exist-31415", "password": "anything"})
if w2.Code != http.StatusUnauthorized || respCode(w2) != "login_error" {
t.Fatalf("unknown user: status = %d, code = %q", w2.Code, respCode(w2))
}
// 未知用户的键必须被计入失败次数(若限流器共享),
// 证明该分支走过了 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")
}
}