package api

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

	"github.com/gofrs/uuid"

	"github.com/pquerna/otp"
	"github.com/supabase/auth/internal/api/sms_provider"
	"github.com/supabase/auth/internal/conf"
	"github.com/supabase/auth/internal/crypto"
	"github.com/supabase/auth/internal/models"
	"github.com/supabase/auth/internal/utilities"

	"github.com/pquerna/otp/totp"
	"github.com/stretchr/testify/require"
	"github.com/stretchr/testify/suite"
)

type MFATestSuite struct {
	suite.Suite
	API                  *API
	Config               *conf.GlobalConfiguration
	TestDomain           string
	TestEmail            string
	TestOTPKey           *otp.Key
	TestPassword         string
	TestUser             *models.User
	TestSession          *models.Session
	TestSecondarySession *models.Session
}

func TestMFA(t *testing.T) {
	api, config, err := setupAPIForTest()
	require.NoError(t, err)
	ts := &MFATestSuite{
		API:    api,
		Config: config,
	}
	defer api.db.Close()
	suite.Run(t, ts)
}

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

	ts.TestEmail = "test@example.com"
	ts.TestPassword = "password"
	// Create user
	u, err := models.NewUser("123456789", ts.TestEmail, ts.TestPassword, 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 Factor
	f := models.NewTOTPFactor(u, "test_factor")
	require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey))
	require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor")
	// Create corresponding session
	s, err := models.NewSession(u.ID, &f.ID)
	require.NoError(ts.T(), err, "Error creating test session")
	require.NoError(ts.T(), ts.API.db.Create(s), "Error saving test session")

	u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.TestEmail, ts.Config.JWT.Aud)
	ts.Require().NoError(err)

	ts.TestUser = u
	ts.TestSession = s

	secondarySession, err := models.NewSession(ts.TestUser.ID, &f.ID)
	require.NoError(ts.T(), err, "Error creating test session")
	require.NoError(ts.T(), ts.API.db.Create(secondarySession), "Error saving test session")

	ts.TestSecondarySession = secondarySession

	// Generate TOTP related settings
	testDomain := strings.Split(ts.TestEmail, "@")[1]
	ts.TestDomain = testDomain

	// By default MFA Phone is disabled
	ts.Config.MFA.Phone.EnrollEnabled = true
	ts.Config.MFA.Phone.VerifyEnabled = true

	ts.Config.MFA.WebAuthn.EnrollEnabled = true
	ts.Config.MFA.WebAuthn.VerifyEnabled = true

	key, err := totp.Generate(totp.GenerateOpts{
		Issuer:      ts.TestDomain,
		AccountName: ts.TestEmail,
	})
	require.NoError(ts.T(), err)
	ts.TestOTPKey = key

}

func (ts *MFATestSuite) generateAAL1Token(user *models.User, sessionId *uuid.UUID) string {
	// Not an actual path. Dummy request to simulate a signup request that we can use in generateAccessToken
	req := httptest.NewRequest(http.MethodPost, "/factors", nil)
	token, _, err := ts.API.generateAccessToken(req, ts.API.db, user, sessionId, models.TOTPSignIn)
	require.NoError(ts.T(), err, "Error generating access token")
	return token
}

func (ts *MFATestSuite) TestEnrollFactor() {
	testFriendlyName := "bob"
	alternativeFriendlyName := "john"

	token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)

	var cases = []struct {
		desc         string
		friendlyName string
		factorType   string
		issuer       string
		phone        string
		expectedCode int
	}{
		{
			desc:         "TOTP: No issuer",
			friendlyName: alternativeFriendlyName,
			factorType:   models.TOTP,
			issuer:       "",
			phone:        "",
			expectedCode: http.StatusOK,
		},
		{
			desc:         "Invalid factor type",
			friendlyName: testFriendlyName,
			factorType:   "invalid_factor",
			issuer:       ts.TestDomain,
			phone:        "",
			expectedCode: http.StatusBadRequest,
		},
		{
			desc:         "TOTP: Factor has friendly name",
			friendlyName: testFriendlyName,
			factorType:   models.TOTP,
			issuer:       ts.TestDomain,
			phone:        "",
			expectedCode: http.StatusOK,
		},
		{
			desc:         "TOTP: Enrolling without friendly name",
			friendlyName: "",
			factorType:   models.TOTP,
			issuer:       ts.TestDomain,
			phone:        "",
			expectedCode: http.StatusOK,
		},
		{
			desc:         "Phone: Enroll with friendly name",
			friendlyName: "phone_factor",
			factorType:   models.Phone,
			phone:        "+12345677889",
			expectedCode: http.StatusOK,
		},
		{
			desc:         "Phone: Enroll with invalid phone number",
			friendlyName: "phone_factor",
			factorType:   models.Phone,
			phone:        "+1",
			expectedCode: http.StatusBadRequest,
		},
		{
			desc:         "Phone: Enroll without phone number should return error",
			friendlyName: "phone_factor_fail",
			factorType:   models.Phone,
			phone:        "",
			expectedCode: http.StatusBadRequest,
		},
		{
			desc:         "WebAuthn: Enroll with friendly name",
			friendlyName: "webauthn_factor",
			factorType:   models.WebAuthn,
			expectedCode: http.StatusOK,
		},
	}
	for _, c := range cases {
		ts.Run(c.desc, func() {
			w := performEnrollFlow(ts, token, c.friendlyName, c.factorType, c.issuer, c.phone, c.expectedCode)
			enrollResp := EnrollFactorResponse{}
			require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))

			if c.expectedCode == http.StatusOK {
				addedFactor, err := models.FindFactorByFactorID(ts.API.db, enrollResp.ID)
				require.NoError(ts.T(), err)
				require.False(ts.T(), addedFactor.IsVerified())

				if c.friendlyName != "" {
					require.Equal(ts.T(), c.friendlyName, addedFactor.FriendlyName)
				}

				if c.factorType == models.TOTP {
					qrCode := enrollResp.TOTP.QRCode
					hasSVGStartAndEnd := strings.Contains(qrCode, "<svg") && strings.Contains(qrCode, "</svg>")
					require.True(ts.T(), hasSVGStartAndEnd)
					require.Equal(ts.T(), c.friendlyName, enrollResp.FriendlyName)
				}
			}

		})
	}
}

func (ts *MFATestSuite) TestDuplicateEnrollPhoneFactor() {
	testPhoneNumber := "+12345677889"
	altPhoneNumber := "+987412444444"
	friendlyName := "phone_factor"
	altFriendlyName := "alt_phone_factor"
	token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)

	var cases = []struct {
		desc                    string
		earlierFactorName       string
		laterFactorName         string
		phone                   string
		secondPhone             string
		expectedCode            int
		expectedNumberOfFactors int
	}{
		{
			desc:                    "Phone: Only the latest factor should persist when enrolling two unverified phone factors with the same number",
			earlierFactorName:       friendlyName,
			laterFactorName:         altFriendlyName,
			phone:                   testPhoneNumber,
			secondPhone:             testPhoneNumber,
			expectedNumberOfFactors: 1,
		},

		{
			desc:                    "Phone: Both factors should persist when enrolling two different unverified numbers",
			earlierFactorName:       friendlyName,
			laterFactorName:         altFriendlyName,
			phone:                   testPhoneNumber,
			secondPhone:             altPhoneNumber,
			expectedNumberOfFactors: 2,
		},
	}

	for _, c := range cases {
		ts.Run(c.desc, func() {
			// Delete all test factors to start from clean slate
			require.NoError(ts.T(), ts.API.db.Destroy(ts.TestUser.Factors))
			_ = performEnrollFlow(ts, token, c.earlierFactorName, models.Phone, ts.TestDomain, c.phone, http.StatusOK)

			w := performEnrollFlow(ts, token, c.laterFactorName, models.Phone, ts.TestDomain, c.secondPhone, http.StatusOK)
			enrollResp := EnrollFactorResponse{}
			require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))

			laterFactor, err := models.FindFactorByFactorID(ts.API.db, enrollResp.ID)
			require.NoError(ts.T(), err)
			require.False(ts.T(), laterFactor.IsVerified())

			require.NoError(ts.T(), ts.API.db.Eager("Factors").Find(ts.TestUser, ts.TestUser.ID))
			require.Equal(ts.T(), len(ts.TestUser.Factors), c.expectedNumberOfFactors)

		})
	}
}

func (ts *MFATestSuite) TestDuplicateEnrollPhoneFactorWithVerified() {
	testPhoneNumber := "+12345677889"
	friendlyName := "phone_factor"
	altFriendlyName := "alt_phone_factor"
	token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)

	ts.Run("Phone: Enrolling a factor with the same number as an existing verified phone factor should result in an error", func() {
		require.NoError(ts.T(), ts.API.db.Destroy(ts.TestUser.Factors))

		// Setup verified factor
		w := performEnrollFlow(ts, token, friendlyName, models.Phone, ts.TestDomain, testPhoneNumber, http.StatusOK)
		enrollResp := EnrollFactorResponse{}
		require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
		firstFactor, err := models.FindFactorByFactorID(ts.API.db, enrollResp.ID)
		require.NoError(ts.T(), err)
		require.NoError(ts.T(), firstFactor.UpdateStatus(ts.API.db, models.FactorStateVerified))

		expectedStatusCode := http.StatusUnprocessableEntity
		_ = performEnrollFlow(ts, token, altFriendlyName, models.Phone, ts.TestDomain, testPhoneNumber, expectedStatusCode)

		require.NoError(ts.T(), ts.API.db.Eager("Factors").Find(ts.TestUser, ts.TestUser.ID))
		require.Equal(ts.T(), len(ts.TestUser.Factors), 1)
	})
}

func (ts *MFATestSuite) TestDuplicateTOTPEnrollsReturnExpectedMessage() {
	friendlyName := "mary"
	issuer := "https://issuer.com"
	token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
	_ = performEnrollFlow(ts, token, friendlyName, models.TOTP, issuer, "", http.StatusOK)
	response := performEnrollFlow(ts, token, friendlyName, models.TOTP, issuer, "", http.StatusUnprocessableEntity)

	var errorResponse HTTPError
	err := json.NewDecoder(response.Body).Decode(&errorResponse)
	require.NoError(ts.T(), err)

	require.Contains(ts.T(), errorResponse.ErrorCode, ErrorCodeMFAFactorNameConflict)
}

func (ts *MFATestSuite) AAL2RequiredToUpdatePasswordAfterEnrollment() {
	resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword, true /* <- requireStatusOK */)
	accessTokenResp := &AccessTokenResponse{}
	require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))

	var w *httptest.ResponseRecorder
	var buffer bytes.Buffer
	token := accessTokenResp.Token
	// Update Password to new password
	newPassword := "newpass"
	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
		"password": newPassword,
	}))

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

	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))

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

	// Logout
	reqURL := "http://localhost/logout"
	req = httptest.NewRequest(http.MethodPost, reqURL, nil)
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
	w = httptest.NewRecorder()

	ts.API.handler.ServeHTTP(w, req)
	require.Equal(ts.T(), http.StatusNoContent, w.Code)

	// Get AAL1 token
	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
		"email":    ts.TestEmail,
		"password": newPassword,
	}))

	req = httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=password", &buffer)
	req.Header.Set("Content-Type", "application/json")
	w = httptest.NewRecorder()
	ts.API.handler.ServeHTTP(w, req)
	require.Equal(ts.T(), http.StatusOK, w.Code)
	session1 := AccessTokenResponse{}
	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&session1))

	// Update Password again, this should fail
	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
		"password": ts.TestPassword,
	}))

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

	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", session1.Token))

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

}

func (ts *MFATestSuite) TestMultipleEnrollsCleanupExpiredFactors() {
	// All factors are deleted when a subsequent enroll is made
	ts.API.config.MFA.FactorExpiryDuration = 0 * time.Second
	// Verified factor should not be deleted (Factor 1)
	resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword, true /* <- requireStatusOK */)
	numFactors := 5
	accessTokenResp := &AccessTokenResponse{}
	require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))

	var w *httptest.ResponseRecorder
	token := accessTokenResp.Token
	for i := 0; i < numFactors; i++ {
		w = performEnrollFlow(ts, token, "first-name", models.TOTP, "https://issuer.com", "", http.StatusOK)
	}

	enrollResp := EnrollFactorResponse{}
	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))

	// Make a challenge so last, unverified factor isn't deleted on next enroll (Factor 2)
	_ = performChallengeFlow(ts, enrollResp.ID, token)

	// Enroll another Factor (Factor 3)
	_ = performEnrollFlow(ts, token, "second-name", models.TOTP, "https://issuer.com", "", http.StatusOK)
	require.NoError(ts.T(), ts.API.db.Eager("Factors").Find(ts.TestUser, ts.TestUser.ID))
	require.Equal(ts.T(), 3, len(ts.TestUser.Factors))
}

func (ts *MFATestSuite) TestChallengeTOTPFactor() {
	// Test Factor is a TOTP Factor
	f := ts.TestUser.Factors[0]
	token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
	w := performChallengeFlow(ts, f.ID, token)
	challengeResp := ChallengeFactorResponse{}
	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&challengeResp))

	require.Equal(ts.T(), http.StatusOK, w.Code)
	require.Equal(ts.T(), challengeResp.Type, models.TOTP)

}

func (ts *MFATestSuite) TestChallengeSMSFactor() {
	// Challenge should still work with phone provider disabled
	ts.Config.External.Phone.Enabled = false
	ts.Config.Hook.SendSMS.Enabled = true
	ts.Config.Hook.SendSMS.URI = "pg-functions://postgres/auth/send_sms_mfa_mock"

	ts.Config.MFA.Phone.MaxFrequency = 0 * time.Second

	require.NoError(ts.T(), ts.Config.Hook.SendSMS.PopulateExtensibilityPoint())
	require.NoError(ts.T(), ts.API.db.RawQuery(`
        create or replace function send_sms_mfa_mock(input jsonb)
        returns json as $$
        begin
            return input;
       end; $$ language plpgsql;`).Exec())

	phone := "+1234567"
	friendlyName := "testchallengesmsfactor"

	f := models.NewPhoneFactor(ts.TestUser, phone, friendlyName)
	require.NoError(ts.T(), ts.API.db.Create(f), "Error creating new SMS factor")
	token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)

	var cases = []struct {
		desc         string
		channel      string
		expectedCode int
	}{
		{
			desc:         "SMS Channel",
			channel:      sms_provider.SMSProvider,
			expectedCode: http.StatusOK,
		},
		{
			desc:         "WhatsApp Channel",
			channel:      sms_provider.WhatsappProvider,
			expectedCode: http.StatusOK,
		},
	}

	for _, tc := range cases {
		ts.Run(tc.desc, func() {
			w := performSMSChallengeFlow(ts, f.ID, token, tc.channel)
			challengeResp := ChallengeFactorResponse{}
			require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&challengeResp))
			require.Equal(ts.T(), challengeResp.Type, models.Phone)
			require.Equal(ts.T(), tc.expectedCode, w.Code, tc.desc)
		})
	}
}

func (ts *MFATestSuite) TestMFAVerifyFactor() {
	cases := []struct {
		desc             string
		validChallenge   bool
		validCode        bool
		factorType       string
		expectedHTTPCode int
	}{
		{
			desc:             "Invalid: Valid code and expired challenge",
			validChallenge:   false,
			validCode:        true,
			factorType:       models.TOTP,
			expectedHTTPCode: http.StatusUnprocessableEntity,
		},
		{
			desc:             "Invalid: Invalid code and valid challenge",
			validChallenge:   true,
			validCode:        false,
			factorType:       models.TOTP,
			expectedHTTPCode: http.StatusUnprocessableEntity,
		},
		{
			desc:             "Valid /verify request",
			validChallenge:   true,
			validCode:        true,
			factorType:       models.TOTP,
			expectedHTTPCode: http.StatusOK,
		},
		{
			desc:             "Invalid: Valid code and expired challenge (SMS)",
			validChallenge:   false,
			validCode:        true,
			factorType:       models.Phone,
			expectedHTTPCode: http.StatusUnprocessableEntity,
		},
		{
			desc:             "Invalid: Invalid code and valid challenge (SMS)",
			validChallenge:   true,
			validCode:        false,
			factorType:       models.Phone,
			expectedHTTPCode: http.StatusUnprocessableEntity,
		},
		{
			desc:             "Valid /verify request (SMS)",
			validChallenge:   true,
			validCode:        true,
			factorType:       models.Phone,
			expectedHTTPCode: http.StatusOK,
		},
	}
	for _, v := range cases {
		ts.Run(v.desc, func() {
			// Authenticate users and set secret
			var buffer bytes.Buffer
			r, err := models.GrantAuthenticatedUser(ts.API.db, ts.TestUser, models.GrantParams{})
			require.NoError(ts.T(), err)
			token := ts.generateAAL1Token(ts.TestUser, r.SessionId)
			var f *models.Factor
			var sharedSecret string

			if v.factorType == models.TOTP {
				friendlyName := uuid.Must(uuid.NewV4()).String()
				f = models.NewTOTPFactor(ts.TestUser, friendlyName)
				sharedSecret = ts.TestOTPKey.Secret()
				f.Secret = sharedSecret
				require.NoError(ts.T(), ts.API.db.Create(f), "Error updating new test factor")
			} else if v.factorType == models.Phone {
				friendlyName := uuid.Must(uuid.NewV4()).String()
				numDigits := 10
				otp := crypto.GenerateOtp(numDigits)
				require.NoError(ts.T(), err)
				phone := fmt.Sprintf("+%s", otp)
				f = models.NewPhoneFactor(ts.TestUser, phone, friendlyName)
				require.NoError(ts.T(), ts.API.db.Create(f), "Error creating new SMS factor")
			}

			w := httptest.NewRecorder()
			req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/factors/%s/verify", f.ID), &buffer)
			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))

			var c *models.Challenge
			var code string
			if v.factorType == models.TOTP {
				c = f.CreateChallenge(utilities.GetIPAddress(req))
				// Verify TOTP code
				code, err = totp.GenerateCode(sharedSecret, time.Now().UTC())
				require.NoError(ts.T(), err)
			} else if v.factorType == models.Phone {
				code = "123456"
				c, err = f.CreatePhoneChallenge(utilities.GetIPAddress(req), code, ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey)
				require.NoError(ts.T(), err)
			}

			if !v.validCode && v.factorType == models.TOTP {
				code, err = totp.GenerateCode(sharedSecret, time.Now().UTC().Add(-1*time.Minute*time.Duration(1)))
				require.NoError(ts.T(), err)

			} else if !v.validCode && v.factorType == models.Phone {
				invalidSuffix := "1"
				code += invalidSuffix
			}

			require.NoError(ts.T(), ts.API.db.Create(c), "Error saving new test challenge")
			if !v.validChallenge {
				// Set challenge creation so that it has expired in present time.
				newCreatedAt := time.Now().UTC().Add(-1 * time.Second * time.Duration(ts.Config.MFA.ChallengeExpiryDuration+1))
				// created_at is managed by buffalo(ORM) needs to be raw query to be updated
				err := ts.API.db.RawQuery("UPDATE auth.mfa_challenges SET created_at = ? WHERE factor_id = ?", newCreatedAt, f.ID).Exec()
				require.NoError(ts.T(), err, "Error updating new test challenge")
			}

			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
				"challenge_id": c.ID,
				"code":         code,
			}))

			ts.API.handler.ServeHTTP(w, req)
			require.Equal(ts.T(), v.expectedHTTPCode, w.Code)

			if v.expectedHTTPCode == http.StatusOK {
				// Ensure alternate session has been deleted
				_, err = models.FindSessionByID(ts.API.db, ts.TestSecondarySession.ID, false)
				require.EqualError(ts.T(), err, models.SessionNotFoundError{}.Error())
			}
			if !v.validChallenge {
				// Ensure invalid challenges are deleted
				_, err := f.FindChallengeByID(ts.API.db, c.ID)
				require.EqualError(ts.T(), err, models.ChallengeNotFoundError{}.Error())
			}
		})
	}
}

func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
	cases := []struct {
		desc             string
		isAAL2           bool
		expectedHTTPCode int
	}{
		{
			desc:             "Verified Factor: AAL1",
			isAAL2:           false,
			expectedHTTPCode: http.StatusUnprocessableEntity,
		},
		{
			desc:             "Verified Factor: AAL2, Success",
			isAAL2:           true,
			expectedHTTPCode: http.StatusOK,
		},
	}
	for _, v := range cases {
		ts.Run(v.desc, func() {
			var buffer bytes.Buffer

			// Create Session to test behaviour which downgrades other sessions
			f := ts.TestUser.Factors[0]
			require.NoError(ts.T(), f.UpdateStatus(ts.API.db, models.FactorStateVerified))
			if v.isAAL2 {
				ts.TestSession.UpdateAALAndAssociatedFactor(ts.API.db, models.AAL2, &f.ID)
			}
			token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
			w := ServeAuthenticatedRequest(ts, http.MethodDelete, fmt.Sprintf("/factors/%s", f.ID), token, buffer)
			require.Equal(ts.T(), v.expectedHTTPCode, w.Code)

			if v.expectedHTTPCode == http.StatusOK {
				_, err := models.FindFactorByFactorID(ts.API.db, f.ID)
				require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
				session, _ := models.FindSessionByID(ts.API.db, ts.TestSecondarySession.ID, false)
				require.Equal(ts.T(), models.AAL1.String(), session.GetAAL())
				require.Nil(ts.T(), session.FactorID)

			}
		})
	}

}

func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
	var buffer bytes.Buffer
	f := ts.TestUser.Factors[0]

	token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
		"factor_id": f.ID,
	}))

	w := ServeAuthenticatedRequest(ts, http.MethodDelete, fmt.Sprintf("/factors/%s", f.ID), token, buffer)
	require.Equal(ts.T(), http.StatusOK, w.Code)

	_, err := models.FindFactorByFactorID(ts.API.db, f.ID)
	require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
	session, _ := models.FindSessionByID(ts.API.db, ts.TestSecondarySession.ID, false)
	require.Equal(ts.T(), models.AAL1.String(), session.GetAAL())
	require.Nil(ts.T(), session.FactorID)

}

// Integration Tests
func (ts *MFATestSuite) TestSessionsMaintainAALOnRefresh() {
	ts.Config.Security.RefreshTokenRotationEnabled = true
	resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword, true /* <- requireStatusOK */)
	accessTokenResp := &AccessTokenResponse{}
	require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))

	var buffer bytes.Buffer
	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
		"refresh_token": accessTokenResp.RefreshToken,
	}))

	req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=refresh_token", &buffer)
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	ts.API.handler.ServeHTTP(w, req)
	require.Equal(ts.T(), http.StatusOK, w.Code)

	data := &AccessTokenResponse{}
	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))

	ctx, err := ts.API.parseJWTClaims(data.Token, req)
	require.NoError(ts.T(), err)
	ctx, err = ts.API.maybeLoadUserOrSession(ctx)
	require.NoError(ts.T(), err)
	require.True(ts.T(), getSession(ctx).IsAAL2())
}

// Performing MFA Verification followed by a sign in should return an AAL1 session and an AAL2 session
func (ts *MFATestSuite) TestMFAFollowedByPasswordSignIn() {
	ts.Config.Security.RefreshTokenRotationEnabled = true
	resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword, true /* <- requireStatusOK */)
	accessTokenResp := &AccessTokenResponse{}
	require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))

	var buffer bytes.Buffer
	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
		"email":    ts.TestEmail,
		"password": ts.TestPassword,
	}))
	req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=password", &buffer)
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	ts.API.handler.ServeHTTP(w, req)
	require.Equal(ts.T(), http.StatusOK, w.Code)

	data := &AccessTokenResponse{}
	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
	ctx, err := ts.API.parseJWTClaims(data.Token, req)
	require.NoError(ts.T(), err)

	ctx, err = ts.API.maybeLoadUserOrSession(ctx)
	require.NoError(ts.T(), err)

	require.Equal(ts.T(), models.AAL1.String(), getSession(ctx).GetAAL())
	session, err := models.FindSessionByUserID(ts.API.db, accessTokenResp.User.ID)
	require.NoError(ts.T(), err)
	require.True(ts.T(), session.IsAAL2())
}

func (ts *MFATestSuite) TestChallengeWebAuthnFactor() {
	factor := models.NewWebAuthnFactor(ts.TestUser, "WebAuthnfactor")
	validWebAuthnConfiguration := &WebAuthnParams{
		RPID:      "localhost",
		RPOrigins: "http://localhost:3000",
	}
	require.NoError(ts.T(), ts.API.db.Create(factor), "Error saving new test factor")
	token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
	w := performChallengeWebAuthnFlow(ts, factor.ID, token, validWebAuthnConfiguration)
	require.Equal(ts.T(), http.StatusOK, w.Code)
}

func performChallengeWebAuthnFlow(ts *MFATestSuite, factorID uuid.UUID, token string, webauthn *WebAuthnParams) *httptest.ResponseRecorder {
	var buffer bytes.Buffer
	err := json.NewEncoder(&buffer).Encode(ChallengeFactorParams{WebAuthn: webauthn})
	require.NoError(ts.T(), err)
	w := ServeAuthenticatedRequest(ts, http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", factorID), token, buffer)
	require.Equal(ts.T(), http.StatusOK, w.Code)
	return w
}

func (ts *MFATestSuite) TestChallengeFactorNotOwnedByUser() {
	var buffer bytes.Buffer
	email := "nomfaenabled@test.com"
	password := "testpassword"
	signUpResp := signUp(ts, email, password)

	friendlyName := "testfactor"
	phoneNumber := "+1234567"

	otherUsersPhoneFactor := models.NewPhoneFactor(ts.TestUser, phoneNumber, friendlyName)
	require.NoError(ts.T(), ts.API.db.Create(otherUsersPhoneFactor), "Error creating factor")

	w := ServeAuthenticatedRequest(ts, http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", otherUsersPhoneFactor.ID), signUpResp.Token, buffer)

	expectedError := notFoundError(ErrorCodeMFAFactorNotFound, "Factor not found")

	var data HTTPError
	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))

	require.Equal(ts.T(), expectedError.ErrorCode, data.ErrorCode)
	require.Equal(ts.T(), http.StatusNotFound, w.Code)

}

func signUp(ts *MFATestSuite, email, password string) (signUpResp AccessTokenResponse) {
	ts.API.config.Mailer.Autoconfirm = true
	var buffer bytes.Buffer

	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
		"email":    email,
		"password": password,
	}))

	// Setup request
	req := httptest.NewRequest(http.MethodPost, "http://localhost/signup", &buffer)
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	ts.API.handler.ServeHTTP(w, req)
	require.Equal(ts.T(), http.StatusOK, w.Code)
	data := AccessTokenResponse{}
	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
	return data
}

func performTestSignupAndVerify(ts *MFATestSuite, email, password string, requireStatusOK bool) *httptest.ResponseRecorder {
	signUpResp := signUp(ts, email, password)
	resp := performEnrollAndVerify(ts, signUpResp.Token, requireStatusOK)

	return resp

}

func performEnrollFlow(ts *MFATestSuite, token, friendlyName, factorType, issuer string, phone string, expectedCode int) *httptest.ResponseRecorder {
	var buffer bytes.Buffer
	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(EnrollFactorParams{FriendlyName: friendlyName, FactorType: factorType, Issuer: issuer, Phone: phone}))
	w := ServeAuthenticatedRequest(ts, http.MethodPost, "http://localhost/factors/", token, buffer)
	require.Equal(ts.T(), expectedCode, w.Code)
	return w
}

func ServeAuthenticatedRequest(ts *MFATestSuite, method, path, token string, buffer bytes.Buffer) *httptest.ResponseRecorder {
	w := httptest.NewRecorder()
	req := httptest.NewRequest(method, path, &buffer)
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
	req.Header.Set("Content-Type", "application/json")

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

func performVerifyFlow(ts *MFATestSuite, challengeID, factorID uuid.UUID, token string, requireStatusOK bool) *httptest.ResponseRecorder {
	var buffer bytes.Buffer

	factor, err := models.FindFactorByFactorID(ts.API.db, factorID)
	require.NoError(ts.T(), err)
	require.NotNil(ts.T(), factor)

	totpSecret := factor.Secret

	if es := crypto.ParseEncryptedString(factor.Secret); es != nil {
		secret, err := es.Decrypt(factor.ID.String(), ts.API.config.Security.DBEncryption.DecryptionKeys)
		require.NoError(ts.T(), err)
		require.NotNil(ts.T(), secret)

		totpSecret = string(secret)
	}

	code, err := totp.GenerateCode(totpSecret, time.Now().UTC())
	require.NoError(ts.T(), err)

	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
		"challenge_id": challengeID,
		"code":         code,
	}))

	y := ServeAuthenticatedRequest(ts, http.MethodPost, fmt.Sprintf("/factors/%s/verify", factorID), token, buffer)

	if requireStatusOK {
		require.Equal(ts.T(), http.StatusOK, y.Code)
	}
	return y
}

func performChallengeFlow(ts *MFATestSuite, factorID uuid.UUID, token string) *httptest.ResponseRecorder {
	var buffer bytes.Buffer
	w := ServeAuthenticatedRequest(ts, http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", factorID), token, buffer)
	require.Equal(ts.T(), http.StatusOK, w.Code)
	return w

}

func performSMSChallengeFlow(ts *MFATestSuite, factorID uuid.UUID, token, channel string) *httptest.ResponseRecorder {
	params := ChallengeFactorParams{
		Channel: channel,
	}
	var buffer bytes.Buffer
	if err := json.NewEncoder(&buffer).Encode(params); err != nil {
		panic(err) // handle the error appropriately in real code
	}

	w := ServeAuthenticatedRequest(ts, http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", factorID), token, buffer)
	require.Equal(ts.T(), http.StatusOK, w.Code)
	return w

}

func performEnrollAndVerify(ts *MFATestSuite, token string, requireStatusOK bool) *httptest.ResponseRecorder {
	w := performEnrollFlow(ts, token, "", models.TOTP, ts.TestDomain, "", http.StatusOK)
	enrollResp := EnrollFactorResponse{}
	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
	factorID := enrollResp.ID

	// Challenge
	w = performChallengeFlow(ts, factorID, token)

	challengeResp := EnrollFactorResponse{}
	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&challengeResp))
	challengeID := challengeResp.ID

	// Verify
	y := performVerifyFlow(ts, challengeID, factorID, token, requireStatusOK)

	return y
}

func (ts *MFATestSuite) TestVerificationHooks() {
	type verificationHookTestCase struct {
		desc                string
		enabled             bool
		uri                 string
		hookFunctionSQL     string
		emailSuffix         string
		expectToken         bool
		expectedCode        int
		cleanupHookFunction string
	}
	cases := []verificationHookTestCase{
		{
			desc:    "Default Success",
			enabled: true,
			uri:     "pg-functions://postgres/auth/verification_hook",
			hookFunctionSQL: `
                create or replace function verification_hook(input jsonb)
                returns json as $$
                begin
                    return json_build_object('decision', 'continue');
                end; $$ language plpgsql;`,
			emailSuffix:         "success",
			expectToken:         true,
			expectedCode:        http.StatusOK,
			cleanupHookFunction: "verification_hook(input jsonb)",
		},
		{
			desc:    "Error",
			enabled: true,
			uri:     "pg-functions://postgres/auth/test_verification_hook_error",
			hookFunctionSQL: `
                create or replace function test_verification_hook_error(input jsonb)
                returns json as $$
                begin
                    RAISE EXCEPTION 'Intentional Error for Testing';
                end; $$ language plpgsql;`,
			emailSuffix:         "error",
			expectToken:         false,
			expectedCode:        http.StatusInternalServerError,
			cleanupHookFunction: "test_verification_hook_error(input jsonb)",
		},
		{
			desc:    "Reject - Enabled",
			enabled: true,
			uri:     "pg-functions://postgres/auth/verification_hook_reject",
			hookFunctionSQL: `
        create or replace function verification_hook_reject(input jsonb)
        returns json as $$
        begin
            return json_build_object(
                'decision', 'reject',
                'message', 'authentication attempt rejected'
            );
        end; $$ language plpgsql;`,
			emailSuffix:         "reject_enabled",
			expectToken:         false,
			expectedCode:        http.StatusForbidden,
			cleanupHookFunction: "verification_hook_reject(input jsonb)",
		},
		{
			desc:    "Reject - Disabled",
			enabled: false,
			uri:     "pg-functions://postgres/auth/verification_hook_reject",
			hookFunctionSQL: `
        create or replace function verification_hook_reject(input jsonb)
        returns json as $$
        begin
            return json_build_object(
                'decision', 'reject',
                'message', 'authentication attempt rejected'
            );
        end; $$ language plpgsql;`,
			emailSuffix:         "reject_disabled",
			expectToken:         true,
			expectedCode:        http.StatusOK,
			cleanupHookFunction: "verification_hook_reject(input jsonb)",
		},
		{
			desc:    "Timeout",
			enabled: true,
			uri:     "pg-functions://postgres/auth/test_verification_hook_timeout",
			hookFunctionSQL: `
        create or replace function test_verification_hook_timeout(input jsonb)
        returns json as $$
        begin
            PERFORM pg_sleep(3);
            return json_build_object(
                'decision', 'continue'
            );
        end; $$ language plpgsql;`,
			emailSuffix:         "timeout",
			expectToken:         false,
			expectedCode:        http.StatusInternalServerError,
			cleanupHookFunction: "test_verification_hook_timeout(input jsonb)",
		},
	}

	for _, c := range cases {
		ts.T().Run(c.desc, func(t *testing.T) {
			ts.Config.Hook.MFAVerificationAttempt.Enabled = c.enabled
			ts.Config.Hook.MFAVerificationAttempt.URI = c.uri
			require.NoError(ts.T(), ts.Config.Hook.MFAVerificationAttempt.PopulateExtensibilityPoint())

			err := ts.API.db.RawQuery(c.hookFunctionSQL).Exec()
			require.NoError(t, err)

			email := fmt.Sprintf("testemail_%s@gmail.com", c.emailSuffix)
			password := "testpassword"
			resp := performTestSignupAndVerify(ts, email, password, c.expectToken)
			require.Equal(ts.T(), c.expectedCode, resp.Code)
			accessTokenResp := &AccessTokenResponse{}
			require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))

			if c.expectToken {
				require.NotEqual(t, "", accessTokenResp.Token)
			} else {
				require.Equal(t, "", accessTokenResp.Token)
			}

			cleanupHook(ts, c.cleanupHookFunction)
		})
	}
}

func cleanupHook(ts *MFATestSuite, hookName string) {
	cleanupHookSQL := fmt.Sprintf("drop function if exists %s", hookName)
	err := ts.API.db.RawQuery(cleanupHookSQL).Exec()
	require.NoError(ts.T(), err)
}
