forked from alterminal/alterminal
重組前檢查點:根目錄 main package
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func formPost(body string, cookie *http.Cookie) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// 未帶 Session Cookie 時不會查詢資料庫,因此 handler 可以傳入 nil db。
|
||||
func TestLoginPageRendersForm(t *testing.T) {
|
||||
h := loginPageHandler(nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Fatalf("Content-Type = %q, want text/html", ct)
|
||||
}
|
||||
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") || !strings.Contains(csp, "style-src 'self'") {
|
||||
t.Fatalf("Content-Security-Policy = %q, 應停用外部資源載入且樣式僅允許本站", csp)
|
||||
}
|
||||
for _, want := range []string{`<form`, `name="username"`, `name="password"`, `name="csrf_token"`, `/static/css/main.css`} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Fatalf("登入表單缺少 %s", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Set-Cookie"), csrfCookieName) {
|
||||
t.Fatal("輸出表單時應設定 CSRF Cookie")
|
||||
}
|
||||
}
|
||||
|
||||
// renderLoggedInPage 不查詢資料庫,可直接以虛構 Session 測試側邊導覽欄版面。
|
||||
func TestRenderLoggedInPageSidebar(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
s := &Session{
|
||||
ID: "test-session",
|
||||
User: User{Username: "alice", Email: "alice@example.com"},
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
renderLoggedInPage(rec, httptest.NewRequest(http.MethodGet, "/login", nil), http.StatusOK, s, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"<aside", // 側邊導覽欄
|
||||
`aria-label="側邊導覽列"`,
|
||||
"alterminal", // 品牌區
|
||||
`href="/"`, // 導覽項目(帳號首頁)
|
||||
`aria-current="page"`,
|
||||
"帳號資訊",
|
||||
`id="sidebar-toggle"`, // 手機版純 CSS 開合(CSP 不允許 JS)
|
||||
`action="/logout"`, // 側欄頁尾的登出表單
|
||||
`name="csrf_token"`,
|
||||
"alice@example.com",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("已登入頁缺少 %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderLoginPageStaysStandalone(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
renderLoginPage(rec, httptest.NewRequest(http.MethodGet, "/login", nil), http.StatusOK, "", "")
|
||||
if strings.Contains(rec.Body.String(), "<aside") {
|
||||
t.Fatal("登入表單頁應維持獨立版面,不含側邊導覽欄")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderLoginPageEscapesPrefill(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
renderLoginPage(rec, httptest.NewRequest(http.MethodGet, "/login", nil),
|
||||
http.StatusUnauthorized, "帳號或密碼錯誤", "<script>alert(1)</script>")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, "<script>") {
|
||||
t.Fatal("預填帳號須經 HTML 轉義")
|
||||
}
|
||||
if !strings.Contains(body, "<script>") {
|
||||
t.Fatal("預填帳號應以轉義後的值輸出")
|
||||
}
|
||||
if !strings.Contains(body, "帳號或密碼錯誤") {
|
||||
t.Fatal("應顯示錯誤訊息")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCSRF(t *testing.T) {
|
||||
cookie := &http.Cookie{Name: csrfCookieName, Value: "token-A"}
|
||||
tests := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
want bool
|
||||
}{
|
||||
{"相符", formPost("csrf_token=token-A&username=a&password=b", cookie), true},
|
||||
{"不相符", formPost("csrf_token=token-B&username=a&password=b", cookie), false},
|
||||
{"缺少 Cookie", formPost("csrf_token=token-A&username=a&password=b", nil), false},
|
||||
{"缺少欄位", formPost("username=a&password=b", cookie), false},
|
||||
{"空欄位", formPost("csrf_token=&username=a&password=b", cookie), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := verifyCSRF(tt.req); got != tt.want {
|
||||
t.Fatalf("verifyCSRF() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 表單流程的錯誤路徑都在查詢資料庫前回應,可用 nil db 測試。
|
||||
func TestLoginHandlerFormRejections(t *testing.T) {
|
||||
h := loginHandler(nil)
|
||||
cookie := &http.Cookie{Name: csrfCookieName, Value: "token-A"}
|
||||
|
||||
t.Run("CSRF 不符回 403 表單", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, formPost("csrf_token=wrong&username=alice&password=sup3r-secret", 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 錯誤訊息")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("缺 password 回 400 表單並保留帳號", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, formPost("csrf_token=token-A&username=alice&password=", cookie))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "password 不可為空") {
|
||||
t.Fatal("應顯示驗證錯誤訊息")
|
||||
}
|
||||
if !strings.Contains(body, `value="alice"`) {
|
||||
t.Fatal("應保留使用者輸入的帳號")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不支援的 Content-Type 回 JSON 415", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader("x=1"))
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user