Files
2026-10-03 12:37:38 +08:00

358 lines
12 KiB
Go

package auth
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestPasswordRequestValidate(t *testing.T) {
tests := []struct {
name string
in passwordRequest
isForm bool
wantErr string // 空字串表示應通過
}{
{"最小欄位", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!"}, false, ""},
{"表單確認欄位相符", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!", Confirm: "n3w-secret!"}, true, ""},
{"缺目前密碼", passwordRequest{NewPassword: "n3w-secret!"}, false, "目前的密碼"},
{"缺新密碼", passwordRequest{CurrentPassword: "sup3r-secret"}, false, "新密碼不可為空"},
{"新密碼過短", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "short"}, false, "密碼長度至少"},
{"表單確認欄位不一致", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!", Confirm: "other-pass"}, true, "兩次輸入的新密碼不一致"},
{"JSON 流程不檢查確認欄位", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!", Confirm: "other-pass"}, false, ""},
{"新密碼與目前密碼相同", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "sup3r-secret"}, false, "不可與目前的密碼相同"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.in.validate(tt.isForm)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("validate() = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("validate() = %v, want error containing %q", err, tt.wantErr)
}
})
}
}
// 未帶 Session Cookie 的路徑不會查詢資料庫,可用 nil db 測試。
func TestPasswordHandlersRequireSession(t *testing.T) {
t.Run("GET 未登入導向 /login 並攜回 next", func(t *testing.T) {
rec := httptest.NewRecorder()
PasswordPageHandler(nil)(rec, httptest.NewRequest(http.MethodGet, "/password", nil))
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fpassword" {
t.Fatalf("Location = %q, want /login?next=%%2Fpassword", loc)
}
})
t.Run("POST 表單未登入導向 /login", func(t *testing.T) {
rec := httptest.NewRecorder()
PasswordChangeHandler(nil)(rec, formPost("csrf_token=token-A", nil))
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fpassword" {
t.Fatalf("Location = %q, want /login?next=%%2Fpassword", loc)
}
})
t.Run("POST JSON 未登入回 401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/password", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
PasswordChangeHandler(nil)(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"error"`) {
t.Fatalf("JSON 流程應回錯誤: %s", rec.Body.String())
}
})
t.Run("不支援的 Content-Type 回 415", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/password", strings.NewReader("x=1"))
req.Header.Set("Content-Type", "text/plain")
rec := httptest.NewRecorder()
PasswordChangeHandler(nil)(rec, req)
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status = %d, want 415", rec.Code)
}
})
}
// renderPasswordPage 不查詢資料庫,可直接以虛構 Session 測試表單輸出。
func TestRenderPasswordPage(t *testing.T) {
rec := httptest.NewRecorder()
s := &Session{
ID: "test-session",
User: User{Username: "alice", Email: "alice@example.com"},
ExpiresAt: time.Now().Add(24 * time.Hour),
}
renderPasswordPage(rec, httptest.NewRequest(http.MethodGet, "/password", nil), http.StatusOK, s, "", "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`action="/password"`,
`name="csrf_token"`,
`name="current_password"`,
`name="new_password"`,
`name="confirm_password"`,
`autocomplete="current-password"`,
`autocomplete="new-password"`,
`minlength="8"`,
"<aside", // layout 側邊導覽欄
} {
if !strings.Contains(body, want) {
t.Errorf("更新密碼頁缺少 %s", want)
}
}
if !strings.Contains(rec.Header().Get("Set-Cookie"), CSRFCookieName) {
t.Fatal("輸出表單時應設定 CSRF Cookie")
}
}
func TestPasswordChangeIntegration(t *testing.T) {
db, err := openTestDB()
if err != nil {
t.Skipf("資料庫不可用,略過整合測試: %v", err)
}
suffix, err := NewToken(6)
if err != nil {
t.Fatal(err)
}
u := &User{Username: "pw-" + suffix, Email: "pw-" + suffix + "@example.com", Name: "Password Test"}
if err := u.SetPassword("sup3r-secret"); err != nil {
t.Fatal(err)
}
if err := db.Create(u).Error; err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
db.Delete(&Session{}, "user_id = ?", u.ID)
db.Delete(&User{}, u.ID)
})
passwordPost := func(form url.Values, sessionID, csrf string) *http.Request {
req := formPost(form.Encode(), &http.Cookie{Name: CSRFCookieName, Value: csrf})
if sessionID != "" {
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionID})
}
return req
}
validForm := func() url.Values {
return url.Values{
"csrf_token": {"token-A"},
"current_password": {"sup3r-secret"},
"new_password": {"n3w-secret!"},
"confirm_password": {"n3w-secret!"},
}
}
// sessionCookieValue 從回應的 Set-Cookie 取出新的 Session ID。
sessionCookieValue := func(rec *httptest.ResponseRecorder) string {
for _, sc := range rec.Header().Values("Set-Cookie") {
if strings.HasPrefix(sc, CookieName+"=") {
return strings.SplitN(strings.SplitN(sc, ";", 2)[0], "=", 2)[1]
}
}
return ""
}
t.Run("已登入者 GET 顯示表單;帳號首頁含更新密碼連結", func(t *testing.T) {
s, err := CreateSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/password", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
rec := httptest.NewRecorder()
PasswordPageHandler(db)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), `name="current_password"`) {
t.Fatal("更新密碼表單缺少目前密碼欄位")
}
home := httptest.NewRequest(http.MethodGet, "/", nil)
home.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
homeRec := httptest.NewRecorder()
AccountPageHandler(db)(homeRec, home)
if !strings.Contains(homeRec.Body.String(), `href="/password"`) {
t.Fatal("帳號首頁應含更新密碼連結")
}
})
t.Run("?saved=1 顯示成功訊息", func(t *testing.T) {
s, err := CreateSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/password?saved=1", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
rec := httptest.NewRecorder()
PasswordPageHandler(db)(rec, req)
if !strings.Contains(rec.Body.String(), "密碼已更新") {
t.Fatal("應顯示成功訊息")
}
})
t.Run("CSRF 不符回 403 重繪表單", func(t *testing.T) {
s, err := CreateSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
PasswordChangeHandler(db)(rec, passwordPost(validForm(), s.ID, "token-B"))
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "表單驗證失敗") {
t.Fatal("應顯示 CSRF 錯誤訊息")
}
if _, err := GetSession(db, s.ID); err != nil {
t.Fatalf("CSRF 失敗不應影響 Session: %v", err)
}
})
t.Run("確認欄位不一致回 400 且不更新", func(t *testing.T) {
s, err := CreateSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
form := validForm()
form.Set("confirm_password", "other-pass")
rec := httptest.NewRecorder()
PasswordChangeHandler(db)(rec, passwordPost(form, s.ID, "token-A"))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "兩次輸入的新密碼不一致") {
t.Fatal("應顯示確認欄位錯誤訊息")
}
})
t.Run("目前密碼錯誤回 401 且不更新", func(t *testing.T) {
s, err := CreateSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
form := validForm()
form.Set("current_password", "wrong-secret")
rec := httptest.NewRecorder()
PasswordChangeHandler(db)(rec, passwordPost(form, s.ID, "token-A"))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "目前的密碼錯誤") {
t.Fatal("應顯示目前密碼錯誤訊息")
}
var reloaded User
if err := db.First(&reloaded, u.ID).Error; err != nil {
t.Fatal(err)
}
if !reloaded.CheckPassword("sup3r-secret") {
t.Fatal("密碼不應被更新")
}
if _, err := GetSession(db, s.ID); err != nil {
t.Fatalf("Session 不應被撤銷: %v", err)
}
})
t.Run("表單成功:更新密碼、撤銷全部 Session、輪替目前 Session", func(t *testing.T) {
current, err := CreateSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
other, err := CreateSession(db, u.ID) // 模擬其他裝置的登入
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
PasswordChangeHandler(db)(rec, passwordPost(validForm(), current.ID, "token-A"))
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "/password?saved=1" {
t.Fatalf("Location = %q, want /password?saved=1", loc)
}
for _, old := range []string{current.ID, other.ID} {
if _, err := GetSession(db, old); !errors.Is(err, ErrSessionExpired) {
t.Fatalf("更新後舊 Session %q 應被撤銷, got %v", old, err)
}
}
newID := sessionCookieValue(rec)
if newID == "" || newID == current.ID {
t.Fatalf("應下發輪替後的新 Session Cookie, got %q", newID)
}
ns, err := GetSession(db, newID)
if err != nil {
t.Fatalf("輪替後的 Session 應有效: %v", err)
}
if ns.UserID != u.ID {
t.Fatalf("輪替後 Session 的 UserID = %d, want %d", ns.UserID, u.ID)
}
var reloaded User
if err := db.First(&reloaded, u.ID).Error; err != nil {
t.Fatal(err)
}
if reloaded.CheckPassword("sup3r-secret") {
t.Fatal("舊密碼不應再通過驗證")
}
if !reloaded.CheckPassword("n3w-secret!") {
t.Fatal("新密碼應可通過驗證")
}
})
t.Run("JSON 成功回 204;目前密碼錯誤回 401", func(t *testing.T) {
s, err := CreateSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
jsonPost := func(sessionID, body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/password", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionID})
return req
}
rec := httptest.NewRecorder()
PasswordChangeHandler(db)(rec, jsonPost(s.ID, `{"current_password":"wrong-secret","new_password":"f1nal-secret!"}`))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401, body = %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
PasswordChangeHandler(db)(rec, jsonPost(s.ID, `{"current_password":"n3w-secret!","new_password":"f1nal-secret!"}`))
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204, body = %s", rec.Code, rec.Body.String())
}
if _, err := GetSession(db, s.ID); !errors.Is(err, ErrSessionExpired) {
t.Fatal("JSON 流程亦應撤銷舊 Session")
}
if newID := sessionCookieValue(rec); newID == "" {
t.Fatal("JSON 流程亦應下發輪替後的 Session Cookie")
}
var reloaded User
if err := db.First(&reloaded, u.ID).Error; err != nil {
t.Fatal(err)
}
if !reloaded.CheckPassword("f1nal-secret!") {
t.Fatal("新密碼應可通過驗證")
}
})
}