package api

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"

	mail "github.com/supabase/auth/internal/mailer"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/stretchr/testify/suite"
	"github.com/supabase/auth/internal/conf"
	"github.com/supabase/auth/internal/crypto"
	"github.com/supabase/auth/internal/models"
)

type VerifyTestSuite struct {
	suite.Suite
	API    *API
	Config *conf.GlobalConfiguration
}

func TestVerify(t *testing.T) {
	api, config, err := setupAPIForTest()
	require.NoError(t, err)

	ts := &VerifyTestSuite{
		API:    api,
		Config: config,
	}
	defer api.db.Close()

	suite.Run(t, ts)
}

func (ts *VerifyTestSuite) SetupTest() {
	models.TruncateAll(ts.API.db)

	// Create user
	u, err := models.NewUser("12345678", "test@example.com", "password", ts.Config.JWT.Aud, nil)
	require.NoError(ts.T(), err, "Error creating test user model")
	require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user")

	// Create identity
	i, err := models.NewIdentity(u, "email", map[string]interface{}{
		"sub":            u.ID.String(),
		"email":          "test@example.com",
		"email_verified": false,
	})
	require.NoError(ts.T(), err, "Error creating test identity model")
	require.NoError(ts.T(), ts.API.db.Create(i), "Error saving new test identity")
}

func (ts *VerifyTestSuite) TestVerifyPasswordRecovery() {
	// modify config so we don't hit rate limit from requesting recovery twice in 60s
	ts.Config.SMTP.MaxFrequency = 60
	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)
	u.RecoverySentAt = &time.Time{}
	require.NoError(ts.T(), ts.API.db.Update(u))
	testEmail := "test@example.com"

	cases := []struct {
		desc   string
		body   map[string]interface{}
		isPKCE bool
	}{
		{
			desc: "Implict Flow Recovery",
			body: map[string]interface{}{
				"email": testEmail,
			},
			isPKCE: false,
		},
		{
			desc: "PKCE Flow",
			body: map[string]interface{}{
				"email": testEmail,
				// Code Challenge needs to be at least 43 characters long
				"code_challenge":        "6b151854-cc15-4e29-8db7-3d3a9f15b3066b151854-cc15-4e29-8db7-3d3a9f15b306",
				"code_challenge_method": models.SHA256.String(),
			},
			isPKCE: true,
		},
	}

	for _, c := range cases {
		ts.Run(c.desc, func() {
			// Reset user
			u.EmailConfirmedAt = nil
			require.NoError(ts.T(), ts.API.db.Update(u))
			require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID))

			// Request body
			var buffer bytes.Buffer
			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body))

			// Setup request
			req := httptest.NewRequest(http.MethodPost, "http://localhost/recover", &buffer)
			req.Header.Set("Content-Type", "application/json")

			// Setup response recorder
			w := httptest.NewRecorder()
			ts.API.handler.ServeHTTP(w, req)
			assert.Equal(ts.T(), http.StatusOK, w.Code)

			u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
			require.NoError(ts.T(), err)

			assert.WithinDuration(ts.T(), time.Now(), *u.RecoverySentAt, 1*time.Second)
			assert.False(ts.T(), u.IsConfirmed())

			recoveryToken := u.RecoveryToken

			reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.RecoveryVerification, recoveryToken)
			req = httptest.NewRequest(http.MethodGet, reqURL, nil)

			w = httptest.NewRecorder()
			ts.API.handler.ServeHTTP(w, req)
			assert.Equal(ts.T(), http.StatusSeeOther, w.Code)

			u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
			require.NoError(ts.T(), err)
			assert.True(ts.T(), u.IsConfirmed())

			if c.isPKCE {
				rURL, _ := w.Result().Location()

				f, err := url.ParseQuery(rURL.RawQuery)
				require.NoError(ts.T(), err)
				assert.NotEmpty(ts.T(), f.Get("code"))
			}
		})
	}
}

func (ts *VerifyTestSuite) TestVerifySecureEmailChange() {
	currentEmail := "test@example.com"
	newEmail := "new@example.com"

	// Change from new email to current email and back to new email
	cases := []struct {
		desc         string
		body         map[string]interface{}
		isPKCE       bool
		currentEmail string
		newEmail     string
	}{
		{
			desc: "Implict Flow Email Change",
			body: map[string]interface{}{
				"email": newEmail,
			},
			isPKCE:       false,
			currentEmail: currentEmail,
			newEmail:     newEmail,
		},
		{
			desc: "PKCE Email Change",
			body: map[string]interface{}{
				"email": currentEmail,
				// Code Challenge needs to be at least 43 characters long
				"code_challenge":        "6b151854-cc15-4e29-8db7-3d3a9f15b3066b151854-cc15-4e29-8db7-3d3a9f15b306",
				"code_challenge_method": models.SHA256.String(),
			},
			isPKCE:       true,
			currentEmail: newEmail,
			newEmail:     currentEmail,
		},
	}

	for _, c := range cases {
		ts.Run(c.desc, func() {
			u, err := models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud)
			require.NoError(ts.T(), err)

			// reset user
			u.EmailChangeSentAt = nil
			u.EmailChangeTokenCurrent = ""
			u.EmailChangeTokenNew = ""
			require.NoError(ts.T(), ts.API.db.Update(u))
			require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID))

			// Request body
			var buffer bytes.Buffer
			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body))

			// Setup request
			req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
			req.Header.Set("Content-Type", "application/json")

			// Generate access token for request and a mock session
			var token string
			session, err := models.NewSession(u.ID, nil)
			require.NoError(ts.T(), err)
			require.NoError(ts.T(), ts.API.db.Create(session))

			token, _, err = ts.API.generateAccessToken(req, ts.API.db, u, &session.ID, models.MagicLink)
			require.NoError(ts.T(), err)
			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))

			// Setup response recorder
			w := httptest.NewRecorder()
			ts.API.handler.ServeHTTP(w, req)
			assert.Equal(ts.T(), http.StatusOK, w.Code)

			u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud)
			require.NoError(ts.T(), err)

			currentTokenHash := u.EmailChangeTokenCurrent
			newTokenHash := u.EmailChangeTokenNew

			u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud)
			require.NoError(ts.T(), err)

			assert.WithinDuration(ts.T(), time.Now(), *u.EmailChangeSentAt, 1*time.Second)
			assert.False(ts.T(), u.IsConfirmed())

			// Verify new email
			reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, newTokenHash)
			req = httptest.NewRequest(http.MethodGet, reqURL, nil)

			w = httptest.NewRecorder()
			ts.API.handler.ServeHTTP(w, req)

			require.Equal(ts.T(), http.StatusSeeOther, w.Code)
			urlVal, err := url.Parse(w.Result().Header.Get("Location"))
			ts.Require().NoError(err, "redirect url parse failed")
			var v url.Values
			if !c.isPKCE {
				v, err = url.ParseQuery(urlVal.Fragment)
				ts.Require().NoError(err)
				ts.Require().NotEmpty(v.Get("message"))
			} else if c.isPKCE {
				v, err = url.ParseQuery(urlVal.RawQuery)
				ts.Require().NoError(err)
				ts.Require().NotEmpty(v.Get("message"))

				v, err = url.ParseQuery(urlVal.Fragment)
				ts.Require().NoError(err)
				ts.Require().NotEmpty(v.Get("message"))
			}

			u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud)
			require.NoError(ts.T(), err)
			assert.Equal(ts.T(), singleConfirmation, u.EmailChangeConfirmStatus)

			// Verify old email
			reqURL = fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, currentTokenHash)
			req = httptest.NewRequest(http.MethodGet, reqURL, nil)

			w = httptest.NewRecorder()
			ts.API.handler.ServeHTTP(w, req)
			require.Equal(ts.T(), http.StatusSeeOther, w.Code)

			urlVal, err = url.Parse(w.Header().Get("Location"))
			ts.Require().NoError(err, "redirect url parse failed")
			if !c.isPKCE {
				v, err = url.ParseQuery(urlVal.Fragment)
				ts.Require().NoError(err)
				ts.Require().NotEmpty(v.Get("access_token"))
				ts.Require().NotEmpty(v.Get("expires_in"))
				ts.Require().NotEmpty(v.Get("refresh_token"))
			} else if c.isPKCE {
				v, err = url.ParseQuery(urlVal.RawQuery)
				ts.Require().NoError(err)
				ts.Require().NotEmpty(v.Get("code"))
			}

			// user's email should've been updated to newEmail
			u, err = models.FindUserByEmailAndAudience(ts.API.db, c.newEmail, ts.Config.JWT.Aud)
			require.NoError(ts.T(), err)
			require.Equal(ts.T(), zeroConfirmation, u.EmailChangeConfirmStatus)

			// Reset confirmation status after each test
			u.EmailConfirmedAt = nil
			require.NoError(ts.T(), ts.API.db.Update(u))
		})
	}
}

func (ts *VerifyTestSuite) TestExpiredConfirmationToken() {
	// verify variant testing not necessary in this test as it's testing
	// the ConfirmationSentAt behavior, not the ConfirmationToken behavior

	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)
	u.ConfirmationToken = "asdf3"
	sentTime := time.Now().Add(-48 * time.Hour)
	u.ConfirmationSentAt = &sentTime
	require.NoError(ts.T(), ts.API.db.Update(u))
	require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken))

	// Setup request
	reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.SignupVerification, u.ConfirmationToken)
	req := httptest.NewRequest(http.MethodGet, reqURL, nil)

	// Setup response recorder
	w := httptest.NewRecorder()

	ts.API.handler.ServeHTTP(w, req)
	assert.Equal(ts.T(), http.StatusSeeOther, w.Code)

	rurl, err := url.Parse(w.Header().Get("Location"))
	require.NoError(ts.T(), err, "redirect url parse failed")

	f, err := url.ParseQuery(rurl.Fragment)
	require.NoError(ts.T(), err)
	assert.Equal(ts.T(), ErrorCodeOTPExpired, f.Get("error_code"))
	assert.Equal(ts.T(), "Email link is invalid or has expired", f.Get("error_description"))
	assert.Equal(ts.T(), "access_denied", f.Get("error"))
}

func (ts *VerifyTestSuite) TestInvalidOtp() {
	u, err := models.FindUserByPhoneAndAudience(ts.API.db, "12345678", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)
	sentTime := time.Now().Add(-48 * time.Hour)
	u.ConfirmationToken = "123456"
	u.ConfirmationSentAt = &sentTime
	u.PhoneChange = "22222222"
	u.PhoneChangeToken = "123456"
	u.PhoneChangeSentAt = &sentTime
	u.EmailChange = "test@gmail.com"
	u.EmailChangeTokenNew = "123456"
	u.EmailChangeTokenCurrent = "123456"
	require.NoError(ts.T(), ts.API.db.Update(u))
	require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken))
	require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.PhoneChange, u.PhoneChangeToken, models.PhoneChangeToken))
	require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.EmailChangeTokenCurrent, models.EmailChangeTokenCurrent))
	require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.EmailChange, u.EmailChangeTokenNew, models.EmailChangeTokenNew))

	type ResponseBody struct {
		Code int    `json:"code"`
		Msg  string `json:"msg"`
	}

	expectedResponse := ResponseBody{
		Code: http.StatusForbidden,
		Msg:  "Token has expired or is invalid",
	}

	cases := []struct {
		desc     string
		sentTime time.Time
		body     map[string]interface{}
		expected ResponseBody
	}{
		{
			desc:     "Expired SMS OTP",
			sentTime: time.Now().Add(-48 * time.Hour),
			body: map[string]interface{}{
				"type":  smsVerification,
				"token": u.ConfirmationToken,
				"phone": u.GetPhone(),
			},
			expected: expectedResponse,
		},
		{
			desc:     "Invalid SMS OTP",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":  smsVerification,
				"token": "invalid_otp",
				"phone": u.GetPhone(),
			},
			expected: expectedResponse,
		},
		{
			desc:     "Invalid Phone Change OTP",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":  phoneChangeVerification,
				"token": "invalid_otp",
				"phone": u.PhoneChange,
			},
			expected: expectedResponse,
		},
		{
			desc:     "Invalid Email OTP",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":  mail.SignupVerification,
				"token": "invalid_otp",
				"email": u.GetEmail(),
			},
			expected: expectedResponse,
		},
		{
			desc:     "Invalid Email Change",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":  mail.EmailChangeVerification,
				"token": "invalid_otp",
				"email": u.GetEmail(),
			},
			expected: expectedResponse,
		},
	}

	for _, caseItem := range cases {
		c := caseItem

		ts.Run(c.desc, func() {
			// update token sent time
			sentTime = time.Now()
			u.ConfirmationSentAt = &c.sentTime
			require.NoError(ts.T(), ts.API.db.Update(u))

			var buffer bytes.Buffer
			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body))

			// Setup request
			req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
			req.Header.Set("Content-Type", "application/json")

			// Setup response recorder
			w := httptest.NewRecorder()
			ts.API.handler.ServeHTTP(w, req)

			b, err := io.ReadAll(w.Body)
			require.NoError(ts.T(), err)
			var resp ResponseBody
			err = json.Unmarshal(b, &resp)
			require.NoError(ts.T(), err)
			assert.Equal(ts.T(), c.expected.Code, resp.Code)
			assert.Equal(ts.T(), c.expected.Msg, resp.Msg)

		})
	}
}

func (ts *VerifyTestSuite) TestExpiredRecoveryToken() {
	// verify variant testing not necessary in this test as it's testing
	// the RecoverySentAt behavior, not the RecoveryToken behavior

	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)
	u.RecoveryToken = "asdf3"
	sentTime := time.Now().Add(-48 * time.Hour)
	u.RecoverySentAt = &sentTime
	require.NoError(ts.T(), ts.API.db.Update(u))

	// Setup request
	reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", "signup", u.RecoveryToken)
	req := httptest.NewRequest(http.MethodGet, reqURL, nil)

	// Setup response recorder
	w := httptest.NewRecorder()

	ts.API.handler.ServeHTTP(w, req)

	assert.Equal(ts.T(), http.StatusSeeOther, w.Code, w.Body.String())
}

func (ts *VerifyTestSuite) TestVerifyPermitedCustomUri() {
	// verify variant testing not necessary in this test as it's testing
	// the redirect URL behavior, not the RecoveryToken behavior

	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)
	u.RecoverySentAt = &time.Time{}
	require.NoError(ts.T(), ts.API.db.Update(u))

	// Request body
	var buffer bytes.Buffer
	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
		"email": "test@example.com",
	}))

	// Setup request
	req := httptest.NewRequest(http.MethodPost, "http://localhost/recover", &buffer)
	req.Header.Set("Content-Type", "application/json")

	// Setup response recorder
	w := httptest.NewRecorder()
	ts.API.handler.ServeHTTP(w, req)
	assert.Equal(ts.T(), http.StatusOK, w.Code)

	u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)

	assert.WithinDuration(ts.T(), time.Now(), *u.RecoverySentAt, 1*time.Second)
	assert.False(ts.T(), u.IsConfirmed())

	redirectURL, _ := url.Parse(ts.Config.URIAllowList[0])

	reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s&redirect_to=%s", "recovery", u.RecoveryToken, redirectURL.String())
	req = httptest.NewRequest(http.MethodGet, reqURL, nil)

	w = httptest.NewRecorder()
	ts.API.handler.ServeHTTP(w, req)
	assert.Equal(ts.T(), http.StatusSeeOther, w.Code)
	rURL, _ := w.Result().Location()
	assert.Equal(ts.T(), redirectURL.Hostname(), rURL.Hostname())

	u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)
	assert.True(ts.T(), u.IsConfirmed())
}

func (ts *VerifyTestSuite) TestVerifyNotPermitedCustomUri() {
	// verify variant testing not necessary in this test as it's testing
	// the redirect URL behavior, not the RecoveryToken behavior

	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)
	u.RecoverySentAt = &time.Time{}
	require.NoError(ts.T(), ts.API.db.Update(u))

	// Request body
	var buffer bytes.Buffer
	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
		"email": "test@example.com",
	}))

	// Setup request
	req := httptest.NewRequest(http.MethodPost, "http://localhost/recover", &buffer)
	req.Header.Set("Content-Type", "application/json")

	// Setup response recorder
	w := httptest.NewRecorder()
	ts.API.handler.ServeHTTP(w, req)
	assert.Equal(ts.T(), http.StatusOK, w.Code)

	u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)

	assert.WithinDuration(ts.T(), time.Now(), *u.RecoverySentAt, 1*time.Second)
	assert.False(ts.T(), u.IsConfirmed())

	fakeredirectURL, _ := url.Parse("http://custom-url.com")
	siteURL, _ := url.Parse(ts.Config.SiteURL)

	reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s&redirect_to=%s", "recovery", u.RecoveryToken, fakeredirectURL.String())
	req = httptest.NewRequest(http.MethodGet, reqURL, nil)

	w = httptest.NewRecorder()
	ts.API.handler.ServeHTTP(w, req)
	assert.Equal(ts.T(), http.StatusSeeOther, w.Code)
	rURL, _ := w.Result().Location()
	assert.Equal(ts.T(), siteURL.Hostname(), rURL.Hostname())

	u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)
	assert.True(ts.T(), u.IsConfirmed())
}

func (ts *VerifyTestSuite) TestVerifySignupWithRedirectURLContainedPath() {
	// verify variant testing not necessary in this test as it's testing
	// the redirect URL behavior, not the RecoveryToken behavior

	testCases := []struct {
		desc                string
		siteURL             string
		uriAllowList        []string
		requestredirectURL  string
		expectedredirectURL string
	}{
		{
			desc:                "same site url and redirect url with path",
			siteURL:             "http://localhost:3000/#/",
			uriAllowList:        []string{"http://localhost:3000"},
			requestredirectURL:  "http://localhost:3000/#/",
			expectedredirectURL: "http://localhost:3000/#/",
		},
		{
			desc:                "different site url and redirect url in allow list",
			siteURL:             "https://someapp-something.codemagic.app/#/",
			uriAllowList:        []string{"http://localhost:3000"},
			requestredirectURL:  "http://localhost:3000",
			expectedredirectURL: "http://localhost:3000",
		},
		{
			desc:                "different site url and redirect url not in allow list",
			siteURL:             "https://someapp-something.codemagic.app/#/",
			uriAllowList:        []string{"http://localhost:3000"},
			requestredirectURL:  "http://localhost:3000/docs",
			expectedredirectURL: "https://someapp-something.codemagic.app/#/",
		},
		{
			desc:                "same wildcard site url and redirect url in allow list",
			siteURL:             "http://sub.test.dev:3000/#/",
			uriAllowList:        []string{"http://*.test.dev:3000"},
			requestredirectURL:  "http://sub.test.dev:3000/#/",
			expectedredirectURL: "http://sub.test.dev:3000/#/",
		},
		{
			desc:                "different wildcard site url and redirect url in allow list",
			siteURL:             "http://sub.test.dev/#/",
			uriAllowList:        []string{"http://*.other.dev:3000"},
			requestredirectURL:  "http://sub.other.dev:3000",
			expectedredirectURL: "http://sub.other.dev:3000",
		},
		{
			desc:                "different wildcard site url and redirect url not in allow list",
			siteURL:             "http://test.dev:3000/#/",
			uriAllowList:        []string{"http://*.allowed.dev:3000"},
			requestredirectURL:  "http://sub.test.dev:3000/#/",
			expectedredirectURL: "http://test.dev:3000/#/",
		},
		{
			desc:                "exact mobile deep link redirect url in allow list",
			siteURL:             "http://test.dev:3000/#/",
			uriAllowList:        []string{"twitter://timeline"},
			requestredirectURL:  "twitter://timeline",
			expectedredirectURL: "twitter://timeline",
		},
		// previously the below example was not allowed and with good
		// reason, however users do want flexibility in the redirect
		// URL after the scheme, which is why the example is now corrected
		{
			desc:                "wildcard mobile deep link redirect url in allow list",
			siteURL:             "http://test.dev:3000/#/",
			uriAllowList:        []string{"com.example.app://**"},
			requestredirectURL:  "com.example.app://sign-in/v2",
			expectedredirectURL: "com.example.app://sign-in/v2",
		},
		{
			desc:                "redirect respects . separator",
			siteURL:             "http://localhost:3000",
			uriAllowList:        []string{"http://*.*.dev:3000"},
			requestredirectURL:  "http://foo.bar.dev:3000",
			expectedredirectURL: "http://foo.bar.dev:3000",
		},
		{
			desc:                "redirect does not respect . separator",
			siteURL:             "http://localhost:3000",
			uriAllowList:        []string{"http://*.dev:3000"},
			requestredirectURL:  "http://foo.bar.dev:3000",
			expectedredirectURL: "http://localhost:3000",
		},
		{
			desc:                "redirect respects / separator in url subdirectory",
			siteURL:             "http://localhost:3000",
			uriAllowList:        []string{"http://test.dev:3000/*/*"},
			requestredirectURL:  "http://test.dev:3000/bar/foo",
			expectedredirectURL: "http://test.dev:3000/bar/foo",
		},
		{
			desc:                "redirect does not respect / separator in url subdirectory",
			siteURL:             "http://localhost:3000",
			uriAllowList:        []string{"http://test.dev:3000/*"},
			requestredirectURL:  "http://test.dev:3000/bar/foo",
			expectedredirectURL: "http://localhost:3000",
		},
	}

	for _, tC := range testCases {
		ts.Run(tC.desc, func() {
			// prepare test data
			ts.Config.SiteURL = tC.siteURL
			redirectURL := tC.requestredirectURL
			ts.Config.URIAllowList = tC.uriAllowList
			ts.Config.ApplyDefaults()

			// set verify token to user as it actual do in magic link method
			u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
			require.NoError(ts.T(), err)
			u.ConfirmationToken = "someToken"
			sendTime := time.Now().Add(time.Hour)
			u.ConfirmationSentAt = &sendTime
			require.NoError(ts.T(), ts.API.db.Update(u))
			require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken))

			reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s&redirect_to=%s", "signup", u.ConfirmationToken, redirectURL)
			req := httptest.NewRequest(http.MethodGet, reqURL, nil)

			w := httptest.NewRecorder()
			ts.API.handler.ServeHTTP(w, req)
			assert.Equal(ts.T(), http.StatusSeeOther, w.Code)
			rURL, _ := w.Result().Location()
			assert.Contains(ts.T(), rURL.String(), tC.expectedredirectURL) // redirected url starts with per test value

			u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
			require.NoError(ts.T(), err)
			assert.True(ts.T(), u.IsConfirmed())
			assert.True(ts.T(), u.UserMetaData["email_verified"].(bool))
			assert.True(ts.T(), u.Identities[0].IdentityData["email_verified"].(bool))
		})
	}
}

func (ts *VerifyTestSuite) TestVerifyPKCEOTP() {
	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)
	t := time.Now()
	u.ConfirmationSentAt = &t
	u.RecoverySentAt = &t
	u.EmailChangeSentAt = &t
	require.NoError(ts.T(), ts.API.db.Update(u))

	cases := []struct {
		desc                 string
		payload              *VerifyParams
		authenticationMethod models.AuthenticationMethod
	}{
		{
			desc: "Verify user on signup",
			payload: &VerifyParams{
				Type:  "signup",
				Token: "pkce_confirmation_token",
			},
			authenticationMethod: models.EmailSignup,
		},
		{
			desc: "Verify magiclink",
			payload: &VerifyParams{
				Type:  "magiclink",
				Token: "pkce_recovery_token",
			},
			authenticationMethod: models.MagicLink,
		},
	}
	for _, c := range cases {
		ts.Run(c.desc, func() {
			var buffer bytes.Buffer
			// since the test user is the same, the tokens are being cleared after each successful verification attempt
			// so we create them on each run
			if c.payload.Type == "signup" {
				require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), c.payload.Token, models.ConfirmationToken))
			} else if c.payload.Type == "magiclink" {
				require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), c.payload.Token, models.RecoveryToken))
			}

			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.payload))
			codeChallenge := "codechallengecodechallengcodechallengcodechallengcodechallenge"
			flowState := models.NewFlowState(c.authenticationMethod.String(), codeChallenge, models.SHA256, c.authenticationMethod, &u.ID)
			require.NoError(ts.T(), ts.API.db.Create(flowState))

			requestUrl := fmt.Sprintf("http://localhost/verify?type=%v&token=%v", c.payload.Type, c.payload.Token)
			req := httptest.NewRequest(http.MethodGet, requestUrl, &buffer)
			req.Header.Set("Content-Type", "application/json")

			w := httptest.NewRecorder()
			ts.API.handler.ServeHTTP(w, req)
			assert.Equal(ts.T(), http.StatusSeeOther, w.Code)
			rURL, _ := w.Result().Location()

			u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
			require.NoError(ts.T(), err)
			assert.True(ts.T(), u.IsConfirmed())

			f, err := url.ParseQuery(rURL.RawQuery)
			require.NoError(ts.T(), err)
			assert.NotEmpty(ts.T(), f.Get("code"))
		})
	}

}

func (ts *VerifyTestSuite) TestVerifyBannedUser() {
	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)
	u.ConfirmationToken = "confirmation_token"
	u.RecoveryToken = "recovery_token"
	u.EmailChangeTokenCurrent = "current_email_change_token"
	u.EmailChangeTokenNew = "new_email_change_token"
	t := time.Now()
	u.ConfirmationSentAt = &t
	u.RecoverySentAt = &t
	u.EmailChangeSentAt = &t

	t = time.Now().Add(24 * time.Hour)
	u.BannedUntil = &t
	require.NoError(ts.T(), ts.API.db.Update(u))
	require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken))
	require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.RecoveryToken, models.RecoveryToken))
	require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.EmailChangeTokenCurrent, models.EmailChangeTokenCurrent))
	require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.EmailChangeTokenNew, models.EmailChangeTokenNew))

	cases := []struct {
		desc    string
		payload *VerifyParams
	}{
		{
			desc: "Verify banned user on signup",
			payload: &VerifyParams{
				Type:  "signup",
				Token: u.ConfirmationToken,
			},
		},
		{
			desc: "Verify banned user on invite",
			payload: &VerifyParams{
				Type:  "invite",
				Token: u.ConfirmationToken,
			},
		},
		{
			desc: "Verify banned user on recover",
			payload: &VerifyParams{
				Type:  "recovery",
				Token: u.RecoveryToken,
			},
		},
		{
			desc: "Verify banned user on magiclink",
			payload: &VerifyParams{
				Type:  "magiclink",
				Token: u.RecoveryToken,
			},
		},
		{
			desc: "Verify banned user on email change",
			payload: &VerifyParams{
				Type:  "email_change",
				Token: u.EmailChangeTokenCurrent,
			},
		},
	}

	for _, c := range cases {
		ts.Run(c.desc, func() {
			var buffer bytes.Buffer
			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.payload))

			requestUrl := fmt.Sprintf("http://localhost/verify?type=%v&token=%v", c.payload.Type, c.payload.Token)
			req := httptest.NewRequest(http.MethodGet, requestUrl, &buffer)
			req.Header.Set("Content-Type", "application/json")

			w := httptest.NewRecorder()
			ts.API.handler.ServeHTTP(w, req)
			assert.Equal(ts.T(), http.StatusSeeOther, w.Code)

			rurl, err := url.Parse(w.Header().Get("Location"))
			require.NoError(ts.T(), err, "redirect url parse failed")

			f, err := url.ParseQuery(rurl.Fragment)
			require.NoError(ts.T(), err)
			assert.Equal(ts.T(), ErrorCodeUserBanned, f.Get("error_code"))
		})
	}
}

func (ts *VerifyTestSuite) TestVerifyValidOtp() {
	ts.Config.Mailer.SecureEmailChangeEnabled = true
	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)
	u.EmailChange = "new@example.com"
	u.Phone = "12345678"
	u.PhoneChange = "1234567890"
	require.NoError(ts.T(), ts.API.db.Update(u))

	type expected struct {
		code      int
		tokenHash string
	}

	cases := []struct {
		desc     string
		sentTime time.Time
		body     map[string]interface{}
		expected
	}{
		{
			desc:     "Valid SMS OTP",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":  smsVerification,
				"token": "123456",
				"phone": u.GetPhone(),
			},
			expected: expected{
				code:      http.StatusOK,
				tokenHash: crypto.GenerateTokenHash(u.GetPhone(), "123456"),
			},
		},
		{
			desc:     "Valid Confirmation OTP",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":  mail.SignupVerification,
				"token": "123456",
				"email": u.GetEmail(),
			},
			expected: expected{
				code:      http.StatusOK,
				tokenHash: crypto.GenerateTokenHash(u.GetEmail(), "123456"),
			},
		},
		{
			desc:     "Valid Signup Token Hash",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":       mail.SignupVerification,
				"token_hash": crypto.GenerateTokenHash(u.GetEmail(), "123456"),
			},
			expected: expected{
				code:      http.StatusOK,
				tokenHash: crypto.GenerateTokenHash(u.GetEmail(), "123456"),
			},
		},
		{
			desc:     "Valid Recovery OTP",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":  mail.RecoveryVerification,
				"token": "123456",
				"email": u.GetEmail(),
			},
			expected: expected{
				code:      http.StatusOK,
				tokenHash: crypto.GenerateTokenHash(u.GetEmail(), "123456"),
			},
		},
		{
			desc:     "Valid Email OTP",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":  mail.EmailOTPVerification,
				"token": "123456",
				"email": u.GetEmail(),
			},
			expected: expected{
				code:      http.StatusOK,
				tokenHash: crypto.GenerateTokenHash(u.GetEmail(), "123456"),
			},
		},
		{
			desc:     "Valid Email OTP (email casing shouldn't matter)",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":  mail.EmailOTPVerification,
				"token": "123456",
				"email": strings.ToUpper(u.GetEmail()),
			},
			expected: expected{
				code:      http.StatusOK,
				tokenHash: crypto.GenerateTokenHash(u.GetEmail(), "123456"),
			},
		},
		{
			desc:     "Valid Email Change OTP",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":  mail.EmailChangeVerification,
				"token": "123456",
				"email": u.EmailChange,
			},
			expected: expected{
				code:      http.StatusOK,
				tokenHash: crypto.GenerateTokenHash(u.EmailChange, "123456"),
			},
		},
		{
			desc:     "Valid Phone Change OTP",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":  phoneChangeVerification,
				"token": "123456",
				"phone": u.PhoneChange,
			},
			expected: expected{
				code:      http.StatusOK,
				tokenHash: crypto.GenerateTokenHash(u.PhoneChange, "123456"),
			},
		},
		{
			desc:     "Valid Email Change Token Hash",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":       mail.EmailChangeVerification,
				"token_hash": crypto.GenerateTokenHash(u.EmailChange, "123456"),
			},
			expected: expected{
				code:      http.StatusOK,
				tokenHash: crypto.GenerateTokenHash(u.EmailChange, "123456"),
			},
		},
		{
			desc:     "Valid Email Verification Type",
			sentTime: time.Now(),
			body: map[string]interface{}{
				"type":       mail.EmailOTPVerification,
				"token_hash": crypto.GenerateTokenHash(u.GetEmail(), "123456"),
			},
			expected: expected{
				code:      http.StatusOK,
				tokenHash: crypto.GenerateTokenHash(u.GetEmail(), "123456"),
			},
		},
	}

	for _, caseItem := range cases {
		c := caseItem
		ts.Run(c.desc, func() {
			// create user
			require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID))

			u.ConfirmationSentAt = &c.sentTime
			u.RecoverySentAt = &c.sentTime
			u.EmailChangeSentAt = &c.sentTime
			u.PhoneChangeSentAt = &c.sentTime

			u.ConfirmationToken = c.expected.tokenHash
			u.RecoveryToken = c.expected.tokenHash
			u.EmailChangeTokenNew = c.expected.tokenHash
			u.PhoneChangeToken = c.expected.tokenHash

			require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.ConfirmationToken, models.ConfirmationToken))
			require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.RecoveryToken, models.RecoveryToken))
			require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.EmailChangeTokenNew, models.EmailChangeTokenNew))
			require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.PhoneChangeToken, models.PhoneChangeToken))

			require.NoError(ts.T(), ts.API.db.Update(u))

			var buffer bytes.Buffer
			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body))

			// Setup request
			req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
			req.Header.Set("Content-Type", "application/json")

			// Setup response recorder
			w := httptest.NewRecorder()
			ts.API.handler.ServeHTTP(w, req)
			assert.Equal(ts.T(), c.expected.code, w.Code)
		})
	}
}

func (ts *VerifyTestSuite) TestSecureEmailChangeWithTokenHash() {
	ts.Config.Mailer.SecureEmailChangeEnabled = true
	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
	require.NoError(ts.T(), err)
	u.EmailChange = "new@example.com"
	require.NoError(ts.T(), ts.API.db.Update(u))

	currentEmailChangeToken := crypto.GenerateTokenHash(string(u.Email), "123456")
	newEmailChangeToken := crypto.GenerateTokenHash(u.EmailChange, "123456")

	cases := []struct {
		desc                   string
		firstVerificationBody  map[string]interface{}
		secondVerificationBody map[string]interface{}
		expectedStatus         int
	}{
		{
			desc: "Secure Email Change with Token Hash (Success)",
			firstVerificationBody: map[string]interface{}{
				"type":       mail.EmailChangeVerification,
				"token_hash": currentEmailChangeToken,
			},
			secondVerificationBody: map[string]interface{}{
				"type":       mail.EmailChangeVerification,
				"token_hash": newEmailChangeToken,
			},
			expectedStatus: http.StatusOK,
		},
		{
			desc: "Secure Email Change with Token Hash. Reusing a token hash twice should fail",
			firstVerificationBody: map[string]interface{}{
				"type":       mail.EmailChangeVerification,
				"token_hash": currentEmailChangeToken,
			},
			secondVerificationBody: map[string]interface{}{
				"type":       mail.EmailChangeVerification,
				"token_hash": currentEmailChangeToken,
			},
			expectedStatus: http.StatusForbidden,
		},
	}

	for _, c := range cases {
		ts.Run(c.desc, func() {
			// Set the corresponding email change tokens
			u.EmailChangeTokenCurrent = currentEmailChangeToken
			u.EmailChangeTokenNew = newEmailChangeToken
			require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID))

			require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", currentEmailChangeToken, models.EmailChangeTokenCurrent))
			require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", newEmailChangeToken, models.EmailChangeTokenNew))

			currentTime := time.Now()
			u.EmailChangeSentAt = &currentTime
			require.NoError(ts.T(), ts.API.db.Update(u))

			var buffer bytes.Buffer
			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.firstVerificationBody))

			// Setup request
			req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
			req.Header.Set("Content-Type", "application/json")

			// Setup response recorder
			w := httptest.NewRecorder()
			ts.API.handler.ServeHTTP(w, req)
			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.secondVerificationBody))

			// Setup second request
			req = httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
			req.Header.Set("Content-Type", "application/json")

			// Setup second response recorder
			w = httptest.NewRecorder()
			ts.API.handler.ServeHTTP(w, req)
			assert.Equal(ts.T(), c.expectedStatus, w.Code)
		})
	}
}

func (ts *VerifyTestSuite) TestPrepRedirectURL() {
	escapedMessage := url.QueryEscape(singleConfirmationAccepted)
	cases := []struct {
		desc     string
		message  string
		rurl     string
		flowType models.FlowType
		expected string
	}{
		{
			desc:     "(PKCE): Redirect URL with additional query params",
			message:  singleConfirmationAccepted,
			rurl:     "https://example.com/?first=another&second=other",
			flowType: models.PKCEFlow,
			expected: fmt.Sprintf("https://example.com/?first=another&message=%s&second=other#message=%s", escapedMessage, escapedMessage),
		},
		{
			desc:     "(PKCE): Query params in redirect url are overriden",
			message:  singleConfirmationAccepted,
			rurl:     "https://example.com/?message=Valid+redirect+URL",
			flowType: models.PKCEFlow,
			expected: fmt.Sprintf("https://example.com/?message=%s#message=%s", escapedMessage, escapedMessage),
		},
		{
			desc:     "(Implicit): plain redirect url",
			message:  singleConfirmationAccepted,
			rurl:     "https://example.com/",
			flowType: models.ImplicitFlow,
			expected: fmt.Sprintf("https://example.com/#message=%s", escapedMessage),
		},
		{
			desc:     "(Implicit): query params retained",
			message:  singleConfirmationAccepted,
			rurl:     "https://example.com/?first=another",
			flowType: models.ImplicitFlow,
			expected: fmt.Sprintf("https://example.com/?first=another#message=%s", escapedMessage),
		},
	}
	for _, c := range cases {
		ts.Run(c.desc, func() {
			rurl, err := ts.API.prepRedirectURL(c.message, c.rurl, c.flowType)
			require.NoError(ts.T(), err)
			require.Equal(ts.T(), c.expected, rurl)
		})
	}
}

func (ts *VerifyTestSuite) TestPrepErrorRedirectURL() {
	const DefaultError = "Invalid redirect URL"
	redirectError := fmt.Sprintf("error=invalid_request&error_code=validation_failed&error_description=%s", url.QueryEscape(DefaultError))

	cases := []struct {
		desc     string
		message  string
		rurl     string
		flowType models.FlowType
		expected string
	}{
		{
			desc:     "(PKCE): Error in both query params and hash fragment",
			message:  "Valid redirect URL",
			rurl:     "https://example.com/",
			flowType: models.PKCEFlow,
			expected: fmt.Sprintf("https://example.com/?%s#%s", redirectError, redirectError),
		},
		{
			desc:     "(PKCE): Error with conflicting query params in redirect url",
			message:  DefaultError,
			rurl:     "https://example.com/?error=Error+to+be+overriden",
			flowType: models.PKCEFlow,
			expected: fmt.Sprintf("https://example.com/?%s#%s", redirectError, redirectError),
		},
		{
			desc:     "(Implicit): plain redirect url",
			message:  DefaultError,
			rurl:     "https://example.com/",
			flowType: models.ImplicitFlow,
			expected: fmt.Sprintf("https://example.com/#%s", redirectError),
		},
		{
			desc:     "(Implicit): query params preserved",
			message:  DefaultError,
			rurl:     "https://example.com/?test=param",
			flowType: models.ImplicitFlow,
			expected: fmt.Sprintf("https://example.com/?test=param#%s", redirectError),
		},
	}
	for _, c := range cases {
		ts.Run(c.desc, func() {
			req := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
			rurl, err := ts.API.prepErrorRedirectURL(badRequestError(ErrorCodeValidationFailed, DefaultError), req, c.rurl, c.flowType)
			require.NoError(ts.T(), err)
			require.Equal(ts.T(), c.expected, rurl)
		})
	}
}

func (ts *VerifyTestSuite) TestVerifyValidateParams() {
	cases := []struct {
		desc     string
		params   *VerifyParams
		method   string
		expected error
	}{
		{
			desc: "Successful GET Verify",
			params: &VerifyParams{
				Type:  "signup",
				Token: "some-token-hash",
			},
			method:   http.MethodGet,
			expected: nil,
		},
		{
			desc: "Successful POST Verify (TokenHash)",
			params: &VerifyParams{
				Type:      "signup",
				TokenHash: "some-token-hash",
			},
			method:   http.MethodPost,
			expected: nil,
		},
		{
			desc: "Successful POST Verify (Token)",
			params: &VerifyParams{
				Type:  "signup",
				Token: "some-token",
				Email: "email@example.com",
			},
			method:   http.MethodPost,
			expected: nil,
		},
		// unsuccessful validations
		{
			desc: "Need to send email or phone number with token",
			params: &VerifyParams{
				Type:  "signup",
				Token: "some-token",
			},
			method:   http.MethodPost,
			expected: badRequestError(ErrorCodeValidationFailed, "Only an email address or phone number should be provided on verify"),
		},
		{
			desc: "Cannot send both TokenHash and Token",
			params: &VerifyParams{
				Type:      "signup",
				Token:     "some-token",
				TokenHash: "some-token-hash",
			},
			method:   http.MethodPost,
			expected: badRequestError(ErrorCodeValidationFailed, "Verify requires either a token or a token hash"),
		},
		{
			desc: "No verification type specified",
			params: &VerifyParams{
				Token: "some-token",
				Email: "email@example.com",
			},
			method:   http.MethodPost,
			expected: badRequestError(ErrorCodeValidationFailed, "Verify requires a verification type"),
		},
	}

	for _, c := range cases {
		ts.Run(c.desc, func() {
			req := httptest.NewRequest(c.method, "http://localhost", nil)
			err := c.params.Validate(req, ts.API)
			require.Equal(ts.T(), c.expected, err)
		})
	}
}
