package handler import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "github.com/gofiber/fiber/v2" ) // helper to build a Fiber app with the handler route mounted. func newTestApp(h *AuthHandler) *fiber.App { app := fiber.New() app.Post("/api/v1/auth/password/reset/complete", h.CompletePasswordReset) return app } func newResetFlowTestApp(h *AuthHandler) *fiber.App { app := fiber.New() app.Post("/api/v1/auth/password/reset/verify", h.ProcessPasswordResetToken) app.Post("/api/v1/auth/password/reset/complete", h.CompletePasswordReset) return app } type testRedisRepo struct { values map[string]string } func (m *testRedisRepo) Set(key string, value string, expiration time.Duration) error { if m.values == nil { m.values = map[string]string{} } m.values[key] = value return nil } func (m *testRedisRepo) Get(key string) (string, error) { if m.values == nil { return "", nil } return m.values[key], nil } func (m *testRedisRepo) Delete(key string) error { if m.values != nil { delete(m.values, key) } return nil } func (m *testRedisRepo) StoreVerificationCode(phone, code string) error { return m.Set("sms:"+phone, code, time.Minute) } func (m *testRedisRepo) GetVerificationCode(phone string) (string, error) { return m.Get("sms:" + phone) } func (m *testRedisRepo) DeleteVerificationCode(phone string) error { return m.Delete("sms:" + phone) } func TestCompletePasswordReset_MissingLoginID(t *testing.T) { h := &AuthHandler{} app := newTestApp(h) body, _ := json.Marshal(map[string]string{ "newPassword": "Password1!", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/complete", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400 for missing loginId, got %d", resp.StatusCode) } var got map[string]string if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatalf("failed to decode response: %v", err) } if got["error"] != "Login ID and new password are required" { t.Fatalf("unexpected error message: %v", got["error"]) } } func TestCompletePasswordReset_InvalidPasswordPolicy(t *testing.T) { h := &AuthHandler{} app := newTestApp(h) body, _ := json.Marshal(map[string]string{ "newPassword": "short", // too short + missing complexity }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/complete?loginId=user@example.com", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400 for weak password, got %d", resp.StatusCode) } var got map[string]string if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatalf("failed to decode response: %v", err) } if got["error"] != "비밀번호는 최소 12자 이상이어야 합니다" { t.Fatalf("unexpected error message: %v", got["error"]) } } func TestCompletePasswordReset_NilIDPProvider(t *testing.T) { h := &AuthHandler{} // IdpProvider intentionally nil to hit the configuration error branch app := newTestApp(h) body, _ := json.Marshal(map[string]string{ "newPassword": "StrongPass1!", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/complete?loginId=user@example.com", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("expected 500 when IDP provider is nil, got %d", resp.StatusCode) } var got map[string]string if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatalf("failed to decode response: %v", err) } if got["error"] != "Authentication service not configured" { t.Fatalf("unexpected error message: %v", got["error"]) } } func TestCompletePasswordReset_TokenValueOverridesLoginIDQuery(t *testing.T) { const resetToken = "tok-reset-1" const tokenLoginID = "user@example.com" const wrongLoginID = "wrong@example.com" const newPassword = "StrongPass1!" redis := &testRedisRepo{ values: map[string]string{ prefixPwdResetToken + resetToken: tokenLoginID, }, } idp := &mockIdpProvider{ userExists: true, err: nil, } h := &AuthHandler{ RedisService: redis, IdpProvider: idp, } app := newResetFlowTestApp(h) body, _ := json.Marshal(map[string]string{ "newPassword": newPassword, }) url := fmt.Sprintf( "/api/v1/auth/password/reset/complete?loginId=%s&token=%s", wrongLoginID, resetToken, ) req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if !idp.updateCalled { t.Fatal("expected UpdateUserPassword to be called") } if idp.updatedLoginID != tokenLoginID { t.Fatalf("expected loginId from token(%s), got %s", tokenLoginID, idp.updatedLoginID) } if idp.updatedPassword != newPassword { t.Fatalf("expected newPassword propagated, got %s", idp.updatedPassword) } } func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *testing.T) { const resetToken = "invalid-token" redis := &testRedisRepo{ values: map[string]string{}, } idp := &mockIdpProvider{ userExists: true, err: nil, } h := &AuthHandler{ RedisService: redis, IdpProvider: idp, } app := newResetFlowTestApp(h) body, _ := json.Marshal(map[string]string{ "newPassword": "StrongPass1!", }) req := httptest.NewRequest( http.MethodPost, "/api/v1/auth/password/reset/complete?loginId=user@example.com&token="+resetToken, bytes.NewReader(body), ) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401 for invalid token, got %d", resp.StatusCode) } if idp.updateCalled { t.Fatal("UpdateUserPassword must not be called when token is invalid") } } func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) { const token = "tok-enc" const loginID = "user+alias@example.com" t.Setenv("USERFRONT_URL", "https://sss.hmac.kr") redis := &testRedisRepo{ values: map[string]string{ prefixPwdResetToken + token: loginID, }, } h := &AuthHandler{ RedisService: redis, } app := newResetFlowTestApp(h) req := httptest.NewRequest( http.MethodPost, "/api/v1/auth/password/reset/verify?token="+token, nil, ) resp, err := app.Test(req) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Fatalf("expected 302, got %d", resp.StatusCode) } location := resp.Header.Get("Location") if location == "" { t.Fatal("missing redirect location") } redirectReq := httptest.NewRequest(http.MethodGet, location, nil) gotLoginID := redirectReq.URL.Query().Get("loginId") if gotLoginID != loginID { t.Fatalf("expected encoded loginId round-trip=%s, got %s (location=%s)", loginID, gotLoginID, location) } }