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) } }) }