package auth import ( "errors" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) func TestPasswordRequestValidate(t *testing.T) { tests := []struct { name string in passwordRequest isForm bool wantErr string // 空字串表示應通過 }{ {"最小欄位", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!"}, false, ""}, {"表單確認欄位相符", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!", Confirm: "n3w-secret!"}, true, ""}, {"缺目前密碼", passwordRequest{NewPassword: "n3w-secret!"}, false, "目前的密碼"}, {"缺新密碼", passwordRequest{CurrentPassword: "sup3r-secret"}, false, "新密碼不可為空"}, {"新密碼過短", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "short"}, false, "密碼長度至少"}, {"表單確認欄位不一致", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!", Confirm: "other-pass"}, true, "兩次輸入的新密碼不一致"}, {"JSON 流程不檢查確認欄位", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!", Confirm: "other-pass"}, false, ""}, {"新密碼與目前密碼相同", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "sup3r-secret"}, false, "不可與目前的密碼相同"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.in.validate(tt.isForm) 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) } }) } } // 未帶 Session Cookie 的路徑不會查詢資料庫,可用 nil db 測試。 func TestPasswordHandlersRequireSession(t *testing.T) { t.Run("GET 未登入導向 /login 並攜回 next", func(t *testing.T) { rec := httptest.NewRecorder() PasswordPageHandler(nil)(rec, httptest.NewRequest(http.MethodGet, "/password", nil)) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303", rec.Code) } if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fpassword" { t.Fatalf("Location = %q, want /login?next=%%2Fpassword", loc) } }) t.Run("POST 表單未登入導向 /login", func(t *testing.T) { rec := httptest.NewRecorder() PasswordChangeHandler(nil)(rec, formPost("csrf_token=token-A", nil)) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303", rec.Code) } if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fpassword" { t.Fatalf("Location = %q, want /login?next=%%2Fpassword", loc) } }) t.Run("POST JSON 未登入回 401", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/password", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() PasswordChangeHandler(nil)(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } if !strings.Contains(rec.Body.String(), `"error"`) { t.Fatalf("JSON 流程應回錯誤: %s", rec.Body.String()) } }) t.Run("不支援的 Content-Type 回 415", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/password", strings.NewReader("x=1")) req.Header.Set("Content-Type", "text/plain") rec := httptest.NewRecorder() PasswordChangeHandler(nil)(rec, req) if rec.Code != http.StatusUnsupportedMediaType { t.Fatalf("status = %d, want 415", rec.Code) } }) } // renderPasswordPage 不查詢資料庫,可直接以虛構 Session 測試表單輸出。 func TestRenderPasswordPage(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), } renderPasswordPage(rec, httptest.NewRequest(http.MethodGet, "/password", 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{ `action="/password"`, `name="csrf_token"`, `name="current_password"`, `name="new_password"`, `name="confirm_password"`, `autocomplete="current-password"`, `autocomplete="new-password"`, `minlength="8"`, "