forked from alterminal/alterminal
171 lines
5.9 KiB
Go
171 lines
5.9 KiB
Go
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())
|
|
}
|
|
})
|
|
}
|