mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-24 21:17:31 +00:00
258 lines
8.9 KiB
Go
258 lines
8.9 KiB
Go
package emailverification
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/italypaleale/francis/actor"
|
|
"github.com/italypaleale/francis/host/local"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
|
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
|
)
|
|
|
|
type testUserProvider struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func (p testUserProvider) GetUser(ctx context.Context, userID string) (model.User, error) {
|
|
var user model.User
|
|
err := p.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error
|
|
return user, err
|
|
}
|
|
|
|
type testEmailSender struct {
|
|
err error
|
|
sent []sentVerificationEmail
|
|
}
|
|
|
|
type sentVerificationEmail struct {
|
|
userFullName string
|
|
userEmail string
|
|
verificationLink string
|
|
}
|
|
|
|
func (s *testEmailSender) SendEmailVerification(_ context.Context, _ *appconfig.AppConfigModel, userFullName, userEmail, verificationLink string) error {
|
|
if s.err != nil {
|
|
return s.err
|
|
}
|
|
|
|
s.sent = append(s.sent, sentVerificationEmail{
|
|
userFullName: userFullName,
|
|
userEmail: userEmail,
|
|
verificationLink: verificationLink,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func newServiceForTest(t *testing.T, emailSender *testEmailSender) (*Service, *local.Host, *gorm.DB) {
|
|
t.Helper()
|
|
|
|
db := testutils.NewDatabaseForTest(t)
|
|
|
|
var service *Service
|
|
host := testutils.NewActorHostForTest(t, func(t *testing.T, host *local.Host) {
|
|
require.NoError(t, host.RegisterActor(ActorType, NewActor))
|
|
service = newService(db, host.Service(), testUserProvider{db: db}, emailSender, "https://id.example.test")
|
|
})
|
|
require.NotNil(t, service)
|
|
|
|
return service, host, db
|
|
}
|
|
|
|
func createTestUser(t *testing.T, db *gorm.DB, userID, address string) model.User {
|
|
t.Helper()
|
|
|
|
user := model.User{
|
|
Base: model.Base{ID: userID},
|
|
Username: userID,
|
|
Email: &address,
|
|
FirstName: "Test",
|
|
LastName: "User",
|
|
}
|
|
require.NoError(t, db.Create(&user).Error)
|
|
|
|
return user
|
|
}
|
|
|
|
func verificationTokenFromEmail(t *testing.T, sentEmail sentVerificationEmail) string {
|
|
t.Helper()
|
|
|
|
verificationURL, err := url.Parse(sentEmail.verificationLink)
|
|
require.NoError(t, err)
|
|
|
|
token := verificationURL.Query().Get("token")
|
|
require.NotEmpty(t, token)
|
|
return token
|
|
}
|
|
|
|
func TestSendBindsAddressAndReplacesOutstandingToken(t *testing.T) {
|
|
emailSender := &testEmailSender{}
|
|
service, host, db := newServiceForTest(t, emailSender)
|
|
user := createTestUser(t, db, "user-1", "user@example.test")
|
|
|
|
require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID))
|
|
firstToken := verificationTokenFromEmail(t, emailSender.sent[0])
|
|
require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID))
|
|
secondToken := verificationTokenFromEmail(t, emailSender.sent[1])
|
|
|
|
var state State
|
|
require.NoError(t, host.GetState(t.Context(), ActorType, user.ID, &state))
|
|
require.Equal(t, "user@example.test", state.Email)
|
|
require.Equal(t, utils.CreateSha256Hash(secondToken), state.TokenHash)
|
|
require.NotEqual(t, firstToken, secondToken)
|
|
require.Len(t, emailSender.sent, 2)
|
|
}
|
|
|
|
func TestVerifyConsumesTokenAndMarksBoundAddressVerified(t *testing.T) {
|
|
emailSender := &testEmailSender{}
|
|
service, host, db := newServiceForTest(t, emailSender)
|
|
user := createTestUser(t, db, "user-2", "user@example.test")
|
|
|
|
require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID))
|
|
token := verificationTokenFromEmail(t, emailSender.sent[0])
|
|
require.NoError(t, service.Verify(t.Context(), user.ID, token))
|
|
|
|
var updated model.User
|
|
require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error)
|
|
require.True(t, updated.EmailVerified)
|
|
|
|
var state State
|
|
require.ErrorIs(t, host.GetState(t.Context(), ActorType, user.ID, &state), actor.ErrStateNotFound)
|
|
}
|
|
|
|
func TestVerifyRejectsTokenAfterAddressChanges(t *testing.T) {
|
|
emailSender := &testEmailSender{}
|
|
service, host, db := newServiceForTest(t, emailSender)
|
|
user := createTestUser(t, db, "user-3", "attacker-controlled@example.test")
|
|
|
|
require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID))
|
|
token := verificationTokenFromEmail(t, emailSender.sent[0])
|
|
require.NoError(t, db.Model(&model.User{}).Where("id = ?", user.ID).Updates(map[string]any{
|
|
"email": "victim@example.test",
|
|
"email_verified": false,
|
|
}).Error)
|
|
|
|
err := service.Verify(t.Context(), user.ID, token)
|
|
require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid))
|
|
|
|
var updated model.User
|
|
require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error)
|
|
require.Equal(t, "victim@example.test", *updated.Email)
|
|
require.False(t, updated.EmailVerified)
|
|
|
|
var state State
|
|
require.ErrorIs(t, host.GetState(t.Context(), ActorType, user.ID, &state), actor.ErrStateNotFound)
|
|
}
|
|
|
|
func TestVerifyDoesNotConsumeStateForWrongToken(t *testing.T) {
|
|
emailSender := &testEmailSender{}
|
|
service, host, db := newServiceForTest(t, emailSender)
|
|
user := createTestUser(t, db, "user-4", "user@example.test")
|
|
|
|
require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID))
|
|
|
|
err := service.Verify(t.Context(), user.ID, "wrong-verification-code")
|
|
require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid))
|
|
|
|
var state State
|
|
require.NoError(t, host.GetState(t.Context(), ActorType, user.ID, &state))
|
|
require.NotEmpty(t, state.TokenHash)
|
|
}
|
|
|
|
func TestVerifyRejectsExpiredToken(t *testing.T) {
|
|
emailSender := &testEmailSender{}
|
|
service, host, db := newServiceForTest(t, emailSender)
|
|
user := createTestUser(t, db, "user-expired", "user@example.test")
|
|
token := "expired-verification-token"
|
|
|
|
require.NoError(t, host.SetState(t.Context(), ActorType, user.ID, State{
|
|
TokenHash: utils.CreateSha256Hash(token),
|
|
Email: *user.Email,
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
}, &actor.SetStateOpts{TTL: time.Millisecond}))
|
|
require.Eventually(t, func() bool {
|
|
var state State
|
|
return errors.Is(host.GetState(t.Context(), ActorType, user.ID, &state), actor.ErrStateNotFound)
|
|
}, time.Second, time.Millisecond)
|
|
|
|
err := service.Verify(t.Context(), user.ID, token)
|
|
require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid))
|
|
|
|
var updated model.User
|
|
require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error)
|
|
require.False(t, updated.EmailVerified)
|
|
}
|
|
|
|
func TestVerifyRestoresActorStateAfterDatabaseWriteFailure(t *testing.T) {
|
|
emailSender := &testEmailSender{}
|
|
service, host, db := newServiceForTest(t, emailSender)
|
|
user := createTestUser(t, db, "user-restore", "user@example.test")
|
|
|
|
require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID))
|
|
token := verificationTokenFromEmail(t, emailSender.sent[0])
|
|
|
|
forcedError := errors.New("forced database write failure")
|
|
require.NoError(t, db.Callback().Update().Before("gorm:update").Register("test:fail-email-verification-update", func(tx *gorm.DB) {
|
|
_ = tx.AddError(forcedError)
|
|
}))
|
|
|
|
require.ErrorIs(t, service.Verify(t.Context(), user.ID, token), forcedError)
|
|
|
|
var state State
|
|
require.NoError(t, host.GetState(t.Context(), ActorType, user.ID, &state))
|
|
require.Equal(t, utils.CreateSha256Hash(token), state.TokenHash)
|
|
require.Equal(t, *user.Email, state.Email)
|
|
}
|
|
|
|
func TestVerifyPreservesNewActorStateAfterDatabaseWriteFailure(t *testing.T) {
|
|
emailSender := &testEmailSender{}
|
|
service, host, db := newServiceForTest(t, emailSender)
|
|
user := createTestUser(t, db, "user-concurrent-issue", "user@example.test")
|
|
|
|
require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID))
|
|
token := verificationTokenFromEmail(t, emailSender.sent[0])
|
|
replacement := State{
|
|
TokenHash: "new-token-hash",
|
|
Email: *user.Email,
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
}
|
|
|
|
forcedError := errors.New("forced database write failure")
|
|
require.NoError(t, db.Callback().Update().Before("gorm:update").Register("test:issue-token-before-email-verification-update-fails", func(tx *gorm.DB) {
|
|
_, err := host.Service().Invoke(tx.Statement.Context, ActorType, user.ID, MethodIssue, replacement)
|
|
if err != nil {
|
|
_ = tx.AddError(err)
|
|
return
|
|
}
|
|
_ = tx.AddError(forcedError)
|
|
}))
|
|
|
|
require.ErrorIs(t, service.Verify(t.Context(), user.ID, token), forcedError)
|
|
|
|
var state State
|
|
require.NoError(t, host.GetState(t.Context(), ActorType, user.ID, &state))
|
|
require.Equal(t, replacement.TokenHash, state.TokenHash)
|
|
require.Equal(t, replacement.Email, state.Email)
|
|
require.True(t, replacement.ExpiresAt.Equal(state.ExpiresAt))
|
|
}
|
|
|
|
func TestSendDiscardsTokenWhenEmailDeliveryFails(t *testing.T) {
|
|
emailSender := &testEmailSender{err: errors.New("delivery failed")}
|
|
service, host, db := newServiceForTest(t, emailSender)
|
|
user := createTestUser(t, db, "user-5", "user@example.test")
|
|
|
|
require.ErrorContains(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID), "delivery failed")
|
|
|
|
var state State
|
|
require.ErrorIs(t, host.GetState(t.Context(), ActorType, user.ID, &state), actor.ErrStateNotFound)
|
|
}
|