forked from alterminal/alterminal
157 lines
5.0 KiB
Go
157 lines
5.0 KiB
Go
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"))
|
|
}
|
|
}
|