重組前檢查點:根目錄 main package

This commit is contained in:
2026-10-03 09:23:38 +08:00
commit f373cb8d37
45 changed files with 5332 additions and 0 deletions
+170
View File
@@ -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, "&lt;script&gt;") {
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())
}
})
}