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")) } }