forked from baron/baron-sso
329 lines
8.6 KiB
Go
329 lines
8.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"baron-sso-backend/internal/middleware"
|
|
"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
|
|
}
|
|
|
|
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 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 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"])
|
|
}
|
|
}
|