Files
2026-10-03 10:44:29 +08:00

204 lines
6.5 KiB
Go

package auth
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// sessionCookieCleared 檢查回應是否以 Max-Age=0 清除 Session Cookie。
func sessionCookieCleared(rec *httptest.ResponseRecorder) bool {
for _, sc := range rec.Header().Values("Set-Cookie") {
if strings.HasPrefix(sc, CookieName+"=") && strings.Contains(sc, "Max-Age=0") {
return true
}
}
return false
}
// 以下拒絕路徑皆不觸及資料庫,可用 nil db 測試。
func TestLogoutHandlerJSONWithoutCookie(t *testing.T) {
h := LogoutHandler(nil)
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204, body = %s", rec.Code, rec.Body.String())
}
if !sessionCookieCleared(rec) {
t.Fatalf("應清除 Session Cookie, Set-Cookie = %v", rec.Header().Values("Set-Cookie"))
}
}
func TestLogoutHandlerRejectsUnsupportedContentType(t *testing.T) {
h := LogoutHandler(nil)
req := httptest.NewRequest(http.MethodPost, "/logout", strings.NewReader("x=1"))
req.Header.Set("Content-Type", "text/plain")
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status = %d, want 415", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"error"`) {
t.Fatalf("應回 JSON 錯誤: %s", rec.Body.String())
}
}
func TestLogoutHandlerFormCSRFRejections(t *testing.T) {
h := LogoutHandler(nil)
t.Run("CSRF 不符回 403 並重繪登入表單", func(t *testing.T) {
cookie := &http.Cookie{Name: CSRFCookieName, Value: "token-A"}
rec := httptest.NewRecorder()
h(rec, formPost("csrf_token=token-B", cookie))
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("Content-Type = %q, want text/html", rec.Header().Get("Content-Type"))
}
if !strings.Contains(rec.Body.String(), "表單驗證失敗") {
t.Fatal("應顯示 CSRF 錯誤訊息")
}
if !strings.Contains(rec.Header().Get("Set-Cookie"), CSRFCookieName) {
t.Fatal("重繪表單時應輪替 CSRF Cookie")
}
})
t.Run("缺 CSRF Cookie 回 403", func(t *testing.T) {
rec := httptest.NewRecorder()
h(rec, formPost("csrf_token=token-A", nil))
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
t.Run("表單無法解析回 400", func(t *testing.T) {
cookie := &http.Cookie{Name: CSRFCookieName, Value: "token-A"}
rec := httptest.NewRecorder()
h(rec, formPost("csrf_token=%zz", cookie))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if !strings.Contains(rec.Body.String(), "無法解析表單內容") {
t.Fatal("應顯示解析錯誤訊息")
}
})
}
func TestLogoutIntegration(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: "logout-" + suffix, Email: "logout-" + suffix + "@example.com", Name: "Logout 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)
})
t.Run("已登入首頁含登出表單", func(t *testing.T) {
s, err := CreateSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
rec := httptest.NewRecorder()
AccountPageHandler(db)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{`action="/logout"`, `name="csrf_token"`, "登出"} {
if !strings.Contains(body, want) {
t.Fatalf("已登入頁缺少 %s", want)
}
}
})
t.Run("已登入者造訪 /login 導向 /", func(t *testing.T) {
s, err := CreateSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
rec := httptest.NewRecorder()
LoginPageHandler(db)(rec, req)
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 != "/" {
t.Fatalf("Location = %q, want /", loc)
}
})
t.Run("表單登出刪除 Session 並導向 /login", func(t *testing.T) {
s, err := CreateSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
req := formPost("csrf_token=token-A", &http.Cookie{Name: CSRFCookieName, Value: "token-A"})
req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
rec := httptest.NewRecorder()
LogoutHandler(db)(rec, req)
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 != "/login" {
t.Fatalf("Location = %q, want /login", loc)
}
if !sessionCookieCleared(rec) {
t.Fatalf("應清除 Session Cookie, Set-Cookie = %v", rec.Header().Values("Set-Cookie"))
}
if _, err := GetSession(db, s.ID); !errors.Is(err, ErrSessionExpired) {
t.Fatalf("登出後 GetSession() = %v, want ErrSessionExpired", err)
}
})
t.Run("重複登出冪等", func(t *testing.T) {
req := formPost("csrf_token=token-A", &http.Cookie{Name: CSRFCookieName, Value: "token-A"})
req.AddCookie(&http.Cookie{Name: CookieName, Value: "already-deleted"})
rec := httptest.NewRecorder()
LogoutHandler(db)(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
})
t.Run("JSON 登出回 204", func(t *testing.T) {
s, err := CreateSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
rec := httptest.NewRecorder()
LogoutHandler(db)(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204, body = %s", rec.Code, rec.Body.String())
}
if !sessionCookieCleared(rec) {
t.Fatal("應清除 Session Cookie")
}
if _, err := GetSession(db, s.ID); !errors.Is(err, ErrSessionExpired) {
t.Fatalf("登出後 GetSession() = %v, want ErrSessionExpired", err)
}
})
}