重組前檢查點:根目錄 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
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestLoginRequestValidate(t *testing.T) {
tests := []struct {
name string
in loginRequest
wantErr string // 空字串表示應通過
}{
{"最小欄位", loginRequest{Username: "alice", Password: "sup3r-secret"}, ""},
{"username 帶首尾空白", loginRequest{Username: " alice ", Password: "sup3r-secret"}, ""},
{"缺 username", loginRequest{Password: "sup3r-secret"}, "username"},
{"username 僅空白", loginRequest{Username: " ", Password: "sup3r-secret"}, "username"},
{"缺 password", loginRequest{Username: "alice"}, "password"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.in.validate()
if tt.wantErr == "" {
if err != nil {
t.Fatalf("validate() = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("validate() = %v, want error containing %q", err, tt.wantErr)
}
})
}
}
func TestLoginRequestValidateTrimsUsername(t *testing.T) {
in := loginRequest{Username: " alice\t", Password: "sup3r-secret"}
if err := in.validate(); err != nil {
t.Fatal("validate: ", err)
}
if in.Username != "alice" {
t.Fatalf("validate 後 username = %q, want %q", in.Username, "alice")
}
}
func TestNewRandomToken(t *testing.T) {
for _, n := range []int{16, 32} {
wantLen := (n*8 + 5) / 6 // base64url 無填充的編碼長度
seen := make(map[string]bool)
for i := 0; i < 100; i++ {
token, err := newRandomToken(n)
if err != nil {
t.Fatal("newRandomToken: ", err)
}
if len(token) != wantLen {
t.Fatalf("n=%d token 長度 = %d, want %d", n, len(token), wantLen)
}
if seen[token] {
t.Fatalf("n=%d token 重複: %s", n, token)
}
seen[token] = true
}
}
}
// 無效請求應在查詢資料庫前就回應,因此 handler 可以傳入 nil db 進行測試。
func TestLoginHandlerRejectsInvalidInput(t *testing.T) {
h := loginHandler(nil)
plainReq := httptest.NewRequest(http.MethodPost, "/login",
strings.NewReader(`{"username":"alice","password":"sup3r-secret"}`))
jsonReq := func(body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
return req
}
tests := []struct {
name string
req *http.Request
wantStatus int
}{
{"Content-Type 非 JSON", plainReq, http.StatusUnsupportedMediaType},
{"JSON 格式錯誤", jsonReq(`{username:`), http.StatusBadRequest},
{"缺 username", jsonReq(`{"password":"sup3r-secret"}`), http.StatusBadRequest},
{"缺 password", jsonReq(`{"username":"alice"}`), http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := httptest.NewRecorder()
h(rec, tt.req)
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d, body = %s", rec.Code, tt.wantStatus, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("Content-Type = %q, want application/json", ct)
}
if !strings.Contains(rec.Body.String(), `"error"`) {
t.Fatalf("回應應為 JSON error 格式: %s", rec.Body.String())
}
})
}
}
func TestNewPublicUserOmitsPasswordHash(t *testing.T) {
u := &User{ID: 7, Username: "alice", Email: "alice@example.com", Name: "Alice", Role: RoleAdmin, PasswordHash: "$argon2id$secret"}
pu := newPublicUser(u)
if pu.ID != 7 || pu.Username != "alice" || pu.Email != "alice@example.com" || pu.Name != "Alice" || pu.Role != RoleAdmin {
t.Fatalf("newPublicUser() = %+v, 欄位不符", pu)
}
b, err := json.Marshal(pu)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "argon2") {
t.Fatalf("回應不得含密碼雜湊: %s", b)
}
}
// 表單登入成功後以 303 導向帳號首頁 /,而非停留在 /login。
func TestLoginHandlerFormSuccessRedirectsHome(t *testing.T) {
db, err := openDB()
if err != nil {
t.Skipf("資料庫不可用,略過整合測試: %v", err)
}
suffix, err := newRandomToken(6)
if err != nil {
t.Fatal(err)
}
u := &User{Username: "login-" + suffix, Email: "login-" + suffix + "@example.com", Name: "Login Test"}
if err := u.SetPassword("sup3r-secret"); err != nil {
t.Fatal(err)
}
if err := db.Create(u).Error; err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
db.Delete(&Session{}, "user_id = ?", u.ID)
db.Delete(&User{}, u.ID)
})
body := "csrf_token=token-A&username=" + u.Username + "&password=sup3r-secret"
req := formPost(body, &http.Cookie{Name: csrfCookieName, Value: "token-A"})
rec := httptest.NewRecorder()
loginHandler(db)(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "/" {
t.Fatalf("Location = %q, want /", loc)
}
if !strings.Contains(rec.Header().Get("Set-Cookie"), sessionCookieName) {
t.Fatalf("登入成功應設定 Session Cookie, Set-Cookie = %v", rec.Header().Values("Set-Cookie"))
}
}