522 lines
14 KiB
Go
522 lines
14 KiB
Go
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)
|
|
}
|
|
}
|