package handler import ( "baron-sso-backend/internal/middleware" "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "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 } func newResetInitAppWithErrorCodeEnricher(h *AuthHandler) *fiber.App { app := fiber.New() app.Use(middleware.ErrorCodeEnricher()) app.Post("/api/v1/auth/password/reset/init", h.InitiatePasswordReset) 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 TestCompletePasswordReset_DuplicateTokenSubmitIsIdempotent(t *testing.T) { const resetToken = "dup-token" const loginID = "user@example.com" const newPassword = "StrongPass1!" redis := &testRedisRepo{ values: map[string]string{ prefixPwdResetToken + resetToken: loginID, }, } 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?token=%s", resetToken, ) firstReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) firstReq.Header.Set("Content-Type", "application/json") firstResp, err := app.Test(firstReq) if err != nil { t.Fatalf("first request failed: %v", err) } defer firstResp.Body.Close() if firstResp.StatusCode != http.StatusOK { t.Fatalf("expected first response to be 200, got %d", firstResp.StatusCode) } if idp.updateCallCount != 1 { t.Fatalf("expected first request to update password once, got %d", idp.updateCallCount) } secondReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) secondReq.Header.Set("Content-Type", "application/json") secondResp, err := app.Test(secondReq) if err != nil { t.Fatalf("second request failed: %v", err) } defer secondResp.Body.Close() if secondResp.StatusCode != http.StatusOK { t.Fatalf("expected duplicate response to be 200, got %d", secondResp.StatusCode) } if idp.updateCallCount != 1 { t.Fatalf("expected duplicate request not to update password again, got %d", idp.updateCallCount) } } 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) } } func TestPasswordResetVerifyAlias_AcceptsShortVePath(t *testing.T) { const token = "tok-ve" const loginID = "user@example.com" redis := &testRedisRepo{ values: map[string]string{ prefixPwdResetToken + token: loginID, }, } h := &AuthHandler{ RedisService: redis, } app := fiber.New() app.Get("/api/v1/auth/password/reset/ve", h.VerifyPasswordResetPage) app.Post("/api/v1/auth/password/reset/ve", h.ProcessPasswordResetToken) getReq := httptest.NewRequest( http.MethodGet, "/api/v1/auth/password/reset/ve?token="+token, nil, ) getResp, err := app.Test(getReq) if err != nil { t.Fatalf("get request failed: %v", err) } defer getResp.Body.Close() if getResp.StatusCode != http.StatusOK { t.Fatalf("expected alias GET to return 200, got %d", getResp.StatusCode) } postReq := httptest.NewRequest( http.MethodPost, "/api/v1/auth/password/reset/ve?token="+token, nil, ) postResp, err := app.Test(postReq) if err != nil { t.Fatalf("post request failed: %v", err) } defer postResp.Body.Close() if postResp.StatusCode != http.StatusFound { t.Fatalf("expected alias POST to return 302, got %d", postResp.StatusCode) } } func TestPasswordResetVerifyPathToken_AcceptsShortVPath(t *testing.T) { const token = "tok-path" const loginID = "user@example.com" redis := &testRedisRepo{ values: map[string]string{ prefixPwdResetToken + token: loginID, }, } h := &AuthHandler{ RedisService: redis, } app := fiber.New() app.Get("/api/v1/auth/password/reset/v/:token", h.VerifyPasswordResetPage) app.Post("/api/v1/auth/password/reset/v/:token", h.ProcessPasswordResetToken) getReq := httptest.NewRequest( http.MethodGet, "/api/v1/auth/password/reset/v/"+token, nil, ) getResp, err := app.Test(getReq) if err != nil { t.Fatalf("get request failed: %v", err) } defer getResp.Body.Close() if getResp.StatusCode != http.StatusOK { t.Fatalf("expected path-token GET to return 200, got %d", getResp.StatusCode) } postReq := httptest.NewRequest( http.MethodPost, "/api/v1/auth/password/reset/v/"+token, nil, ) postResp, err := app.Test(postReq) if err != nil { t.Fatalf("post request failed: %v", err) } defer postResp.Body.Close() if postResp.StatusCode != http.StatusFound { t.Fatalf("expected path-token POST to return 302, got %d", postResp.StatusCode) } } func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) { h := &AuthHandler{} app := newResetInitAppWithErrorCodeEnricher(h) body, _ := json.Marshal(map[string]string{ "loginId": "", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/init", 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, got %d", resp.StatusCode) } var got map[string]any if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatalf("failed to decode response: %v", err) } if got["error"] != "Login ID is required" { t.Fatalf("unexpected error message: %v", got["error"]) } if got["code"] != "bad_request" { t.Fatalf("expected code=bad_request, got %v", got["code"]) } } func TestInitiatePasswordReset_SmsContainsVerifyLink(t *testing.T) { t.Setenv("USERFRONT_URL", "https://sss.hmac.kr") redis := &testRedisRepo{values: map[string]string{}} smsSvc := &mockSmsService{} h := &AuthHandler{ RedisService: redis, IdpProvider: &mockIdpProvider{}, SmsService: smsSvc, } app := fiber.New() app.Post("/api/v1/auth/password/reset/init", h.InitiatePasswordReset) body, _ := json.Marshal(map[string]string{ "loginId": "01012345678", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/init", 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 !strings.Contains(smsSvc.lastContent, "/api/v1/auth/password/reset/v/") { t.Fatalf("expected SMS to contain short path verify link, got %q", smsSvc.lastContent) } if strings.Contains(smsSvc.lastContent, "/reset-password?token=") { t.Fatalf("expected direct reset-password link to be removed, got %q", smsSvc.lastContent) } }