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