satellite/console: don't delete expired project invitations

This change removes instances of project invitation deletion due to
expiration because we now want such invitations to be accessible beyond
their expiration date. In the future, project members will be able to
view and resend expired invitations within the Team page in the
satellite frontend.

References #5752

Change-Id: If24a9637945874d719b894a66c06f6e0e9805dfa
This commit is contained in:
Jeremy Wharton 2023-06-20 00:45:45 -05:00
parent b6026b9ff3
commit d18f4f7d99
7 changed files with 232 additions and 104 deletions

View File

@ -22,6 +22,8 @@ type ProjectInvitations interface {
GetByProjectID(ctx context.Context, projectID uuid.UUID) ([]ProjectInvitation, error)
// GetByEmail returns all of the project member invitations for the specified email address.
GetByEmail(ctx context.Context, email string) ([]ProjectInvitation, error)
// Update updates the project member invitation specified by the given project ID and email address.
Update(ctx context.Context, projectID uuid.UUID, email string, request UpdateProjectInvitationRequest) (*ProjectInvitation, error)
// Delete removes a project member invitation from the database.
Delete(ctx context.Context, projectID uuid.UUID, email string) error
// DeleteBefore deletes project member invitations created prior to some time from the database.
@ -35,3 +37,9 @@ type ProjectInvitation struct {
InviterID *uuid.UUID
CreatedAt time.Time
}
// UpdateProjectInvitationRequest contains all fields which may be updated by ProjectInvitations.Update.
type UpdateProjectInvitationRequest struct {
CreatedAt *time.Time
InviterID *uuid.UUID
}

View File

@ -75,7 +75,7 @@ const (
projInviteInvalidErrMsg = "The invitation has expired or is invalid"
projInviteAlreadyMemberErrMsg = "You are already a member of the project"
projInviteResponseInvalidErrMsg = "Invalid project member invitation response"
projInviteExistsErrMsg = "User has already been invited"
projInviteActiveErrMsg = "The invitation for '%s' has not expired yet"
)
var (
@ -143,8 +143,8 @@ var (
// or has expired.
ErrProjectInviteInvalid = errs.Class("invalid project invitation")
// ErrProjectInviteExists occurs when a user is invited to a project they've already been invited to.
ErrProjectInviteExists = errs.Class("user already invited to project")
// ErrProjectInviteActive occurs when trying to reinvite a user whose invitation hasn't expired yet.
ErrProjectInviteActive = errs.Class("project invitation active")
)
// Service is handling accounts related logic.
@ -3470,26 +3470,10 @@ func (s *Service) GetUserProjectInvitations(ctx context.Context) (_ []ProjectInv
}
var active []ProjectInvitation
var deleteErrs []error
var expiredIDs []string
for _, invite := range invites {
if time.Now().After(invite.CreatedAt.Add(s.config.ProjectInvitationExpiration)) {
err := s.store.ProjectInvitations().Delete(ctx, invite.ProjectID, invite.Email)
if err != nil {
deleteErrs = append(deleteErrs, err)
expiredIDs = append(expiredIDs, invite.ProjectID.String())
}
continue
if !time.Now().After(invite.CreatedAt.Add(s.config.ProjectInvitationExpiration)) {
active = append(active, invite)
}
active = append(active, invite)
}
if len(deleteErrs) != 0 {
s.log.Warn("error deleting expired project invitations",
zap.Errors("errors", deleteErrs),
zap.String("email", user.Email),
zap.Strings("projectIDs", expiredIDs),
)
}
return active, nil
@ -3580,6 +3564,7 @@ func (s *Service) RespondToProjectInvitation(ctx context.Context, projectID uuid
}
// InviteProjectMembers invites users by email to given project.
// If an invitation already exists and has expired, it will be replaced and the user will be sent a new email.
// Email addresses not belonging to a user are ignored.
// projectID here may be project.PublicID or project.ID.
func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID, emails []string) (invites []ProjectInvitation, err error) {
@ -3611,24 +3596,13 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
if err != nil && !errs.Is(err, sql.ErrNoRows) {
return nil, Error.Wrap(err)
}
if invite != nil && time.Now().After(invite.CreatedAt.Add(s.config.ProjectInvitationExpiration)) {
// delete expired invite
err := s.store.ProjectInvitations().Delete(ctx, projectID, invitedUser.Email)
if err != nil {
s.log.Warn("error deleting project invitation",
zap.Error(err),
zap.String("email", invitedUser.Email),
zap.String("projectID", projectID.String()),
)
}
} else if invite != nil && !time.Now().After(invite.CreatedAt.Add(s.config.ProjectInvitationExpiration)) {
return nil, ErrProjectInviteExists.New(projInviteExistsErrMsg)
if invite != nil && !time.Now().After(invite.CreatedAt.Add(s.config.ProjectInvitationExpiration)) {
return nil, ErrProjectInviteActive.New(projInviteActiveErrMsg, invitedUser.Email)
}
users = append(users, invitedUser)
} else if !errs.Is(err, sql.ErrNoRows) {
return nil, Error.Wrap(err)
}
}
inviteTokens := make(map[string]string)
@ -3641,11 +3615,17 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
InviterID: &user.ID,
})
if err != nil {
if dbx.IsConstraintError(err) {
// should not happen, but just in case.
return errs.New("%s is already invited", invited.Email)
if !dbx.IsConstraintError(err) {
return err
}
now := time.Now()
invite, err = tx.ProjectInvitations().Update(ctx, projectID, invited.Email, UpdateProjectInvitationRequest{
CreatedAt: &now,
InviterID: &user.ID,
})
if err != nil {
return err
}
return err
}
token, err := s.CreateInviteToken(ctx, isMember.project.PublicID, invited.Email, invite.CreatedAt.Add(s.config.ProjectInvitationExpiration))
if err != nil {
@ -3707,14 +3687,6 @@ func (s *Service) GetInviteByToken(ctx context.Context, token string) (invite *P
return nil, ErrProjectInviteInvalid.New(projInviteInvalidErrMsg)
}
if time.Now().After(invite.CreatedAt.Add(s.config.ProjectInvitationExpiration)) {
err = s.store.ProjectInvitations().Delete(ctx, invite.ProjectID, invite.Email)
if err != nil {
s.log.Warn("error deleting expired project invitations",
zap.Error(err),
zap.String("email", email),
zap.String("projectID", project.ID.String()),
)
}
return nil, ErrProjectInviteInvalid.New(projInviteInvalidErrMsg)
}

View File

@ -11,7 +11,6 @@ import (
"fmt"
"math/rand"
"sort"
"strings"
"testing"
"time"
@ -1975,23 +1974,7 @@ func TestProjectInvitations(t *testing.T) {
return project
}
setInviteDate := func(ctx context.Context, invite *console.ProjectInvitation, createdAt time.Time) *console.ProjectInvitation {
result, err := sat.DB.Testing().RawDB().ExecContext(ctx,
"UPDATE project_invitations SET created_at = $1 WHERE project_id = $2 AND email = $3",
createdAt, invite.ProjectID, strings.ToUpper(invite.Email),
)
require.NoError(t, err)
count, err := result.RowsAffected()
require.NoError(t, err)
require.EqualValues(t, 1, count)
invite, err = sat.DB.Console().ProjectInvitations().Get(ctx, invite.ProjectID, invite.Email)
require.NoError(t, err)
return invite
}
addInvite := func(t *testing.T, ctx context.Context, project *console.Project, email string, createdAt time.Time) *console.ProjectInvitation {
addInvite := func(t *testing.T, ctx context.Context, project *console.Project, email string) *console.ProjectInvitation {
invite, err := sat.DB.Console().ProjectInvitations().Insert(ctx, &console.ProjectInvitation{
ProjectID: project.ID,
Email: email,
@ -1999,7 +1982,15 @@ func TestProjectInvitations(t *testing.T) {
})
require.NoError(t, err)
return setInviteDate(ctx, invite, createdAt)
return invite
}
expireInvite := func(t *testing.T, ctx context.Context, invite *console.ProjectInvitation) {
createdAt := time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration)
_, err := sat.DB.Console().ProjectInvitations().Update(ctx, invite.ProjectID, invite.Email, console.UpdateProjectInvitationRequest{
CreatedAt: &createdAt,
})
require.NoError(t, err)
}
t.Run("invite users", func(t *testing.T) {
@ -2026,19 +2017,7 @@ func TestProjectInvitations(t *testing.T) {
invites, err = service.GetUserProjectInvitations(ctx3)
require.NoError(t, err)
require.Len(t, invites, 1)
invite := invites[0]
// inviting the same user again should fail if existing invite hasn't expired.
_, err = service.InviteProjectMembers(ctx, project.ID, []string{user3.Email})
require.Error(t, err)
// expire the invitation.
setInviteDate(ctx, &invite, time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration))
// inviting the same user again should succeed because the existing invite has expired.
invites, err = service.InviteProjectMembers(ctx, project.ID, []string{user3.Email})
require.NoError(t, err)
require.Len(t, invites, 1)
user3Invite := invites[0]
// prevent unauthorized users from inviting others (user2 is not a member of the project yet).
_, err = service.InviteProjectMembers(ctx2, project.ID, []string{"other@mail.com"})
@ -2047,9 +2026,18 @@ func TestProjectInvitations(t *testing.T) {
require.NoError(t, service.RespondToProjectInvitation(ctx2, project.ID, console.ProjectInvitationAccept))
// now that user2 is a member, they can invite others.
_, err = service.InviteProjectMembers(ctx2, project.ID, []string{"other@mail.com"})
// resending an active invitation should fail.
invites, err = service.InviteProjectMembers(ctx2, project.ID, []string{user3.Email})
require.True(t, console.ErrProjectInviteActive.Has(err))
require.Empty(t, invites)
// resending an expired invitation should succeed.
expireInvite(t, ctx, &user3Invite)
invites, err = service.InviteProjectMembers(ctx2, project.ID, []string{user3.Email})
require.NoError(t, err)
require.Len(t, invites, 1)
require.Equal(t, user2.ID, *invites[0].InviterID)
require.True(t, invites[0].CreatedAt.After(user3Invite.CreatedAt))
// inviting a project member should fail.
_, err = service.InviteProjectMembers(ctx, project.ID, []string{user2.Email})
@ -2063,7 +2051,7 @@ func TestProjectInvitations(t *testing.T) {
require.NoError(t, err)
require.Empty(t, invites)
invite := addInvite(t, ctx, addProject(t, ctx), user.Email, time.Now())
invite := addInvite(t, ctx, addProject(t, ctx), user.Email)
invites, err = service.GetUserProjectInvitations(ctx)
require.NoError(t, err)
require.Len(t, invites, 1)
@ -2072,7 +2060,7 @@ func TestProjectInvitations(t *testing.T) {
require.Equal(t, invite.InviterID, invites[0].InviterID)
require.WithinDuration(t, invite.CreatedAt, invites[0].CreatedAt, time.Second)
setInviteDate(ctx, invite, time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration))
expireInvite(t, ctx, &invites[0])
invites, err = service.GetUserProjectInvitations(ctx)
require.NoError(t, err)
require.Empty(t, invites)
@ -2118,7 +2106,7 @@ func TestProjectInvitations(t *testing.T) {
project, err := sat.AddProject(ctx, owner.ID, "Test Project")
require.NoError(t, err)
invite := addInvite(t, ctx, project, user.Email, time.Now())
invite := addInvite(t, ctx, project, user.Email)
someToken, err := service.CreateInviteToken(ctx, project.PublicID, "some@email.com", invite.CreatedAt)
require.NoError(t, err)
@ -2136,7 +2124,7 @@ func TestProjectInvitations(t *testing.T) {
require.NotNil(t, inviteFromToken)
require.Equal(t, invite, inviteFromToken)
setInviteDate(ctx, invite, time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration))
expireInvite(t, ctx, invite)
invites, err := service.GetUserProjectInvitations(ctx)
require.NoError(t, err)
require.Empty(t, invites)
@ -2158,11 +2146,12 @@ func TestProjectInvitations(t *testing.T) {
user, ctx := getUserAndCtx(t)
proj := addProject(t, ctx)
addInvite(t, ctx, proj, user.Email, time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration))
invite := addInvite(t, ctx, proj, user.Email)
expireInvite(t, ctx, invite)
err := service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept)
require.True(t, console.ErrProjectInviteInvalid.Has(err))
addInvite(t, ctx, proj, user.Email, time.Now())
addInvite(t, ctx, proj, user.Email)
require.NoError(t, service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept))
invites, err := service.GetUserProjectInvitations(ctx)
@ -2186,7 +2175,7 @@ func TestProjectInvitations(t *testing.T) {
user, ctx := getUserAndCtx(t)
proj := addProject(t, ctx)
addInvite(t, ctx, proj, user.Email, time.Now())
addInvite(t, ctx, proj, user.Email)
require.NoError(t, service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationDecline))
invites, err := service.GetUserProjectInvitations(ctx)

View File

@ -164,9 +164,9 @@ model project_invitation (
// See satellitedb.normalizeEmail for details.
field email text
// inviter_id is the ID of the user who sent the invitation.
field inviter_id user.id setnull ( nullable )
field inviter_id user.id setnull ( nullable, updatable )
// created_at is the time that the invitation was created.
field created_at timestamp ( autoinsert )
field created_at timestamp ( autoinsert, updatable )
)
create project_invitation ( )
@ -187,6 +187,11 @@ read all (
where project_invitation.project_id = ?
)
update project_invitation (
where project_invitation.project_id = ?
where project_invitation.email = ?
)
delete project_invitation (
where project_invitation.project_id = ?
where project_invitation.email = ?

View File

@ -11263,6 +11263,8 @@ type ProjectInvitation_Create_Fields struct {
}
type ProjectInvitation_Update_Fields struct {
InviterId ProjectInvitation_InviterId_Field
CreatedAt ProjectInvitation_CreatedAt_Field
}
type ProjectInvitation_ProjectId_Field struct {
@ -18340,6 +18342,53 @@ func (obj *pgxImpl) Update_Project_By_Id(ctx context.Context,
return project, nil
}
func (obj *pgxImpl) Update_ProjectInvitation_By_ProjectId_And_Email(ctx context.Context,
project_invitation_project_id ProjectInvitation_ProjectId_Field,
project_invitation_email ProjectInvitation_Email_Field,
update ProjectInvitation_Update_Fields) (
project_invitation *ProjectInvitation, err error) {
defer mon.Task()(&ctx)(&err)
var __sets = &__sqlbundle_Hole{}
var __embed_stmt = __sqlbundle_Literals{Join: "", SQLs: []__sqlbundle_SQL{__sqlbundle_Literal("UPDATE project_invitations SET "), __sets, __sqlbundle_Literal(" WHERE project_invitations.project_id = ? AND project_invitations.email = ? RETURNING project_invitations.project_id, project_invitations.email, project_invitations.inviter_id, project_invitations.created_at")}}
__sets_sql := __sqlbundle_Literals{Join: ", "}
var __values []interface{}
var __args []interface{}
if update.InviterId._set {
__values = append(__values, update.InviterId.value())
__sets_sql.SQLs = append(__sets_sql.SQLs, __sqlbundle_Literal("inviter_id = ?"))
}
if update.CreatedAt._set {
__values = append(__values, update.CreatedAt.value())
__sets_sql.SQLs = append(__sets_sql.SQLs, __sqlbundle_Literal("created_at = ?"))
}
if len(__sets_sql.SQLs) == 0 {
return nil, emptyUpdate()
}
__args = append(__args, project_invitation_project_id.value(), project_invitation_email.value())
__values = append(__values, __args...)
__sets.SQL = __sets_sql
var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt)
obj.logStmt(__stmt, __values...)
project_invitation = &ProjectInvitation{}
err = obj.queryRowContext(ctx, __stmt, __values...).Scan(&project_invitation.ProjectId, &project_invitation.Email, &project_invitation.InviterId, &project_invitation.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, obj.makeErr(err)
}
return project_invitation, nil
}
func (obj *pgxImpl) UpdateNoReturn_ApiKey_By_Id(ctx context.Context,
api_key_id ApiKey_Id_Field,
update ApiKey_Update_Fields) (
@ -26300,6 +26349,53 @@ func (obj *pgxcockroachImpl) Update_Project_By_Id(ctx context.Context,
return project, nil
}
func (obj *pgxcockroachImpl) Update_ProjectInvitation_By_ProjectId_And_Email(ctx context.Context,
project_invitation_project_id ProjectInvitation_ProjectId_Field,
project_invitation_email ProjectInvitation_Email_Field,
update ProjectInvitation_Update_Fields) (
project_invitation *ProjectInvitation, err error) {
defer mon.Task()(&ctx)(&err)
var __sets = &__sqlbundle_Hole{}
var __embed_stmt = __sqlbundle_Literals{Join: "", SQLs: []__sqlbundle_SQL{__sqlbundle_Literal("UPDATE project_invitations SET "), __sets, __sqlbundle_Literal(" WHERE project_invitations.project_id = ? AND project_invitations.email = ? RETURNING project_invitations.project_id, project_invitations.email, project_invitations.inviter_id, project_invitations.created_at")}}
__sets_sql := __sqlbundle_Literals{Join: ", "}
var __values []interface{}
var __args []interface{}
if update.InviterId._set {
__values = append(__values, update.InviterId.value())
__sets_sql.SQLs = append(__sets_sql.SQLs, __sqlbundle_Literal("inviter_id = ?"))
}
if update.CreatedAt._set {
__values = append(__values, update.CreatedAt.value())
__sets_sql.SQLs = append(__sets_sql.SQLs, __sqlbundle_Literal("created_at = ?"))
}
if len(__sets_sql.SQLs) == 0 {
return nil, emptyUpdate()
}
__args = append(__args, project_invitation_project_id.value(), project_invitation_email.value())
__values = append(__values, __args...)
__sets.SQL = __sets_sql
var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt)
obj.logStmt(__stmt, __values...)
project_invitation = &ProjectInvitation{}
err = obj.queryRowContext(ctx, __stmt, __values...).Scan(&project_invitation.ProjectId, &project_invitation.Email, &project_invitation.InviterId, &project_invitation.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, obj.makeErr(err)
}
return project_invitation, nil
}
func (obj *pgxcockroachImpl) UpdateNoReturn_ApiKey_By_Id(ctx context.Context,
api_key_id ApiKey_Id_Field,
update ApiKey_Update_Fields) (
@ -29806,6 +29902,18 @@ func (rx *Rx) Update_Node_By_Id(ctx context.Context,
return tx.Update_Node_By_Id(ctx, node_id, update)
}
func (rx *Rx) Update_ProjectInvitation_By_ProjectId_And_Email(ctx context.Context,
project_invitation_project_id ProjectInvitation_ProjectId_Field,
project_invitation_email ProjectInvitation_Email_Field,
update ProjectInvitation_Update_Fields) (
project_invitation *ProjectInvitation, err error) {
var tx *Tx
if tx, err = rx.getTx(ctx); err != nil {
return
}
return tx.Update_ProjectInvitation_By_ProjectId_And_Email(ctx, project_invitation_project_id, project_invitation_email, update)
}
func (rx *Rx) Update_Project_By_Id(ctx context.Context,
project_id Project_Id_Field,
update Project_Update_Fields) (
@ -30793,6 +30901,12 @@ type Methods interface {
update Node_Update_Fields) (
node *Node, err error)
Update_ProjectInvitation_By_ProjectId_And_Email(ctx context.Context,
project_invitation_project_id ProjectInvitation_ProjectId_Field,
project_invitation_email ProjectInvitation_Email_Field,
update ProjectInvitation_Update_Fields) (
project_invitation *ProjectInvitation, err error)
Update_Project_By_Id(ctx context.Context,
project_id Project_Id_Field,
update Project_Update_Fields) (

View File

@ -87,6 +87,30 @@ func (invites *projectInvitations) GetByEmail(ctx context.Context, email string)
return projectInvitationSliceFromDBX(dbxInvites)
}
// Update updates the project member invitation specified by the given project ID and email address.
func (invites *projectInvitations) Update(ctx context.Context, projectID uuid.UUID, email string, request console.UpdateProjectInvitationRequest) (_ *console.ProjectInvitation, err error) {
defer mon.Task()(&ctx)(&err)
update := dbx.ProjectInvitation_Update_Fields{}
if request.CreatedAt != nil {
update.CreatedAt = dbx.ProjectInvitation_CreatedAt(*request.CreatedAt)
}
if request.InviterID != nil {
update.InviterId = dbx.ProjectInvitation_InviterId((*request.InviterID)[:])
}
dbxInvite, err := invites.db.Update_ProjectInvitation_By_ProjectId_And_Email(ctx,
dbx.ProjectInvitation_ProjectId(projectID[:]),
dbx.ProjectInvitation_Email(normalizeEmail(email)),
update,
)
if err != nil {
return nil, err
}
return projectInvitationFromDBX(dbxInvite)
}
// Delete removes a project member invitation from the database.
func (invites *projectInvitations) Delete(ctx context.Context, projectID uuid.UUID, email string) (err error) {
defer mon.Task()(&ctx)(&err)

View File

@ -123,6 +123,27 @@ func TestProjectInvitations(t *testing.T) {
require.Nil(t, invite.InviterID)
})
t.Run("update invitation", func(t *testing.T) {
ctx := testcontext.New(t)
req := console.UpdateProjectInvitationRequest{}
newCreatedAt := invite.CreatedAt.Add(time.Hour)
req.CreatedAt = &newCreatedAt
newInvite, err := invitesDB.Update(ctx, projID, email, req)
require.NoError(t, err)
require.Equal(t, newCreatedAt, newInvite.CreatedAt)
inviter, err := db.Console().Users().Insert(ctx, &console.User{
ID: testrand.UUID(),
PasswordHash: testrand.Bytes(8),
})
require.NoError(t, err)
req.InviterID = &inviter.ID
newInvite, err = invitesDB.Update(ctx, projID, email, req)
require.NoError(t, err)
require.Equal(t, inviter.ID, *newInvite.InviterID)
})
t.Run("delete invitation", func(t *testing.T) {
ctx := testcontext.New(t)
@ -156,13 +177,12 @@ func TestDeleteBefore(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
invitesDB := db.Console().ProjectInvitations()
now := time.Now()
// Only positive page sizes should be allowed.
require.Error(t, invitesDB.DeleteBefore(ctx, time.Time{}, 0, 0))
require.Error(t, invitesDB.DeleteBefore(ctx, time.Time{}, 0, -1))
createInvite := func(createdAt time.Time) *console.ProjectInvitation {
createInvite := func() *console.ProjectInvitation {
projID := testrand.UUID()
_, err := db.Console().Projects().Insert(ctx, &console.Project{ID: projID})
require.NoError(t, err)
@ -170,26 +190,22 @@ func TestDeleteBefore(t *testing.T) {
invite, err := invitesDB.Insert(ctx, &console.ProjectInvitation{ProjectID: projID})
require.NoError(t, err)
result, err := db.Testing().RawDB().ExecContext(ctx,
"UPDATE project_invitations SET created_at = $1 WHERE project_id = $2",
createdAt, invite.ProjectID,
)
require.NoError(t, err)
count, err := result.RowsAffected()
require.NoError(t, err)
require.EqualValues(t, 1, count)
return invite
}
newInvite := createInvite(now)
oldInvite := createInvite(expiration.Add(-time.Second))
newInvite := createInvite()
oldInvite := createInvite()
oldCreatedAt := expiration.Add(-time.Second)
oldInvite, err := invitesDB.Update(ctx, oldInvite.ProjectID, oldInvite.Email, console.UpdateProjectInvitationRequest{
CreatedAt: &oldCreatedAt,
})
require.NoError(t, err)
require.NoError(t, invitesDB.DeleteBefore(ctx, expiration, 0, 1))
// Ensure that the old invitation record was deleted and the other remains.
_, err := invitesDB.Get(ctx, oldInvite.ProjectID, oldInvite.Email)
_, err = invitesDB.Get(ctx, oldInvite.ProjectID, oldInvite.Email)
require.ErrorIs(t, err, sql.ErrNoRows)
_, err = invitesDB.Get(ctx, newInvite.ProjectID, newInvite.Email)
require.NoError(t, err)