forked from alterminal/alterminal
204 lines
6.5 KiB
Go
204 lines
6.5 KiB
Go
package main
|
|
|
|
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, sessionCookieName+"=") && 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 := openDB()
|
|
if err != nil {
|
|
t.Skipf("資料庫不可用,略過整合測試: %v", err)
|
|
}
|
|
suffix, err := newRandomToken(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: sessionCookieName, 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: sessionCookieName, 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: sessionCookieName, 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: sessionCookieName, 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: sessionCookieName, 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)
|
|
}
|
|
})
|
|
}
|