fixup
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nestly/internal/models"
|
||||
)
|
||||
|
||||
// loginView 登入頁的模板資料。
|
||||
type loginView struct {
|
||||
Email string
|
||||
Error string
|
||||
}
|
||||
|
||||
// AuthHandler 處理登入與登出。
|
||||
type AuthHandler struct {
|
||||
deps Dependencies
|
||||
}
|
||||
|
||||
// Login 渲染登入頁;已登入者直接導向首頁。
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := h.deps.Sessions.UserID(r); err == nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := h.deps.Templates.Render(w, http.StatusOK, "login.html", loginView{}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// DoLogin 驗證 Email 與密碼,成功後建立 session 並導向首頁;
|
||||
// 失敗時重新渲染表單並保留使用者輸入的 Email。
|
||||
func (h *AuthHandler) DoLogin(w http.ResponseWriter, r *http.Request) {
|
||||
email := strings.TrimSpace(r.PostFormValue("email"))
|
||||
password := r.PostFormValue("password")
|
||||
|
||||
// 帳號不存在與密碼錯誤回應同一訊息,避免列舉有效 Email。
|
||||
view := loginView{Email: email}
|
||||
acct, err := h.deps.Accounts.FindByEmail(r.Context(), email)
|
||||
if err != nil && !errors.Is(err, models.ErrNotFound) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err == nil && acct.VerifyPassword(password) {
|
||||
h.deps.Sessions.Login(w, acct.ID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
view.Error = "Email 或密碼不正確。"
|
||||
if err := h.deps.Templates.Render(w, http.StatusUnauthorized, "login.html", view); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Logout 清除 session 並回到登入頁。
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
h.deps.Sessions.Logout(w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nestly/internal/auth"
|
||||
"nestly/internal/models"
|
||||
"nestly/internal/templates"
|
||||
"nestly/web"
|
||||
)
|
||||
|
||||
// newTestServer 建立以記憶體 SQLite 為後端的測試路由與示範帳號。
|
||||
func newTestServer(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("開啟記憶體資料庫失敗: %v", err)
|
||||
}
|
||||
accounts := models.NewAccountStore(db)
|
||||
if err := accounts.AutoMigrate(context.Background()); err != nil {
|
||||
t.Fatalf("自動遷移失敗: %v", err)
|
||||
}
|
||||
acct := &models.Account{Email: "demo@nestly.test", Name: "示範帳號", Role: models.RoleMember}
|
||||
if err := acct.SetPassword("nestly1234"); err != nil {
|
||||
t.Fatalf("設定密碼失敗: %v", err)
|
||||
}
|
||||
if err := accounts.Create(context.Background(), acct); err != nil {
|
||||
t.Fatalf("建立帳號失敗: %v", err)
|
||||
}
|
||||
|
||||
templateFS, err := fs.Sub(web.TemplatesFS, "templates")
|
||||
if err != nil {
|
||||
t.Fatalf("建立模板子目錄失敗: %v", err)
|
||||
}
|
||||
engine, err := templates.New(templateFS)
|
||||
if err != nil {
|
||||
t.Fatalf("載入模板失敗: %v", err)
|
||||
}
|
||||
return NewRouter(Dependencies{
|
||||
Templates: engine,
|
||||
Accounts: accounts,
|
||||
Sessions: auth.NewSessionManager("test-secret", false),
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoginPageRenders(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /login 狀態碼 = %d, 想要 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"登入", `name="email"`, `name="password"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("GET /login 回應缺少 %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWrongPassword(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
form := url.Values{"email": {"demo@nestly.test"}, "password": {"wrong-password"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("POST /login(錯誤密碼)狀態碼 = %d, 想要 401", rec.Code)
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Email 或密碼不正確") {
|
||||
t.Error("錯誤密碼應顯示錯誤訊息")
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, `value="demo@nestly.test"`) {
|
||||
t.Error("錯誤後應保留使用者輸入的 Email")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSuccessFlow(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
form := url.Values{"email": {"Demo@Nestly.test "}, "password": {"nestly1234"}} // email 大小寫與空白應被容忍
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST /login(正確)狀態碼 = %d, 想要 303", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/" {
|
||||
t.Errorf("登入成功應導向 /, 實際 %q", loc)
|
||||
}
|
||||
var cookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.CookieName {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
if cookie == nil {
|
||||
t.Fatal("登入成功應設定 session cookie")
|
||||
}
|
||||
|
||||
// 帶著 session cookie 造訪首頁,應顯示帳號名稱而非登入連結。
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /(已登入)狀態碼 = %d, 想要 200", rec.Code)
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, "示範帳號") {
|
||||
t.Error("已登入的首頁應顯示帳號名稱")
|
||||
}
|
||||
|
||||
// 已登入者造訪 /login 應被導回首頁。
|
||||
req = httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("GET /login(已登入)狀態碼 = %d, 想要 303", rec.Code)
|
||||
}
|
||||
|
||||
// 登出後 cookie 失效,首頁不再顯示帳號。
|
||||
req = httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
var cleared *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.CookieName {
|
||||
cleared = c
|
||||
}
|
||||
}
|
||||
if cleared == nil || cleared.MaxAge >= 0 {
|
||||
t.Fatal("登出應清除 session cookie")
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if cleared != nil {
|
||||
req.AddCookie(cleared)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
if body := rec.Body.String(); strings.Contains(body, "示範帳號") {
|
||||
t.Error("登出後首頁不應顯示帳號名稱")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperedSessionRejected(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: "1.9999999999.deadbeef"})
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
if body := rec.Body.String(); strings.Contains(body, "示範帳號") {
|
||||
t.Error("偽造的 session 不應被接受")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticCSSServed(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/static/css/app.css", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /static/css/app.css 狀態碼 = %d, 想要 200(記得先執行 make css)", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"nestly/internal/models"
|
||||
)
|
||||
|
||||
// homeView 首頁的模板資料;未登入時 Account 為 nil。
|
||||
type homeView struct {
|
||||
Account *models.Account
|
||||
}
|
||||
|
||||
// HomeHandler 渲染首頁。
|
||||
type HomeHandler struct {
|
||||
deps Dependencies
|
||||
}
|
||||
|
||||
// Show 顯示首頁;持有有效 session 時一併帶出帳號資料。
|
||||
func (h *HomeHandler) Show(w http.ResponseWriter, r *http.Request) {
|
||||
view := homeView{}
|
||||
if uid, err := h.deps.Sessions.UserID(r); err == nil {
|
||||
if acct, err := h.deps.Accounts.FindByID(r.Context(), uid); err == nil {
|
||||
view.Account = acct
|
||||
}
|
||||
}
|
||||
if err := h.deps.Templates.Render(w, http.StatusOK, "home.html", view); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package handlers 實作 Nestly 的 HTTP handlers 與路由。
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"nestly/internal/auth"
|
||||
"nestly/internal/models"
|
||||
"nestly/internal/templates"
|
||||
"nestly/web"
|
||||
)
|
||||
|
||||
// Dependencies 集中所有 handler 依賴的週邊設施。
|
||||
type Dependencies struct {
|
||||
Templates *templates.Engine
|
||||
Accounts *models.AccountStore
|
||||
Sessions *auth.SessionManager
|
||||
}
|
||||
|
||||
// NewRouter 建立應用程式的完整路由與中介軟體。
|
||||
func NewRouter(deps Dependencies) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
// 靜態資源由內嵌 FS 提供,部署不依賴磁碟上的 web/ 目錄。
|
||||
staticFS, err := fs.Sub(web.StaticFS, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
home := &HomeHandler{deps: deps}
|
||||
authh := &AuthHandler{deps: deps}
|
||||
|
||||
r.Get("/", home.Show)
|
||||
r.Get("/login", authh.Login)
|
||||
r.Post("/login", authh.DoLogin)
|
||||
r.Post("/logout", authh.Logout)
|
||||
|
||||
return r
|
||||
}
|
||||
Reference in New Issue
Block a user