169 lines
5.3 KiB
Go
169 lines
5.3 KiB
Go
package services
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"nearle/config"
|
|
"nearle/models"
|
|
"nearle/repositories"
|
|
)
|
|
|
|
/*
|
|
Re-issuing a merchant's first-password link.
|
|
|
|
Invitations get lost — spam folders, typo'd addresses, a seven-day expiry that
|
|
runs out over a holiday. Without a resend the only recovery is a database edit,
|
|
so this exists.
|
|
|
|
It is also the endpoint most at risk of quietly becoming something else. An
|
|
endpoint that re-issues a working password link for any account IS a password
|
|
reset, whatever it is called, and nothing on this backend verifies identity well
|
|
enough to support one. So most of what follows is about what it refuses.
|
|
*/
|
|
|
|
type inviteRepo struct {
|
|
repositories.TenantRepository
|
|
target repositories.InviteTarget
|
|
err error
|
|
}
|
|
|
|
func (r *inviteRepo) PrimaryAdminForTenant(int) (repositories.InviteTarget, error) {
|
|
if r.err != nil {
|
|
return repositories.InviteTarget{}, r.err
|
|
}
|
|
return r.target, nil
|
|
}
|
|
|
|
type countingInvites struct {
|
|
calls int
|
|
sent bool
|
|
reason string
|
|
}
|
|
|
|
func (c *countingInvites) Invite(_, _ int, _, _ string) (bool, string) {
|
|
c.calls++
|
|
return c.sent, c.reason
|
|
}
|
|
|
|
func waiting() repositories.InviteTarget {
|
|
return repositories.InviteTarget{
|
|
Userid: 904, Email: "owner@rmart.example", Tenantname: "R Mart", IsSetUp: false,
|
|
}
|
|
}
|
|
|
|
func TestResendEmailsAMerchantWhoNeverGotOne(t *testing.T) {
|
|
invites := &countingInvites{sent: true}
|
|
service := NewTenantService(&inviteRepo{target: waiting()}, invites)
|
|
|
|
outcome, err := service.ResendInvite(1147)
|
|
if err != nil {
|
|
t.Fatalf("resend: %v", err)
|
|
}
|
|
if !outcome.Sent || invites.calls != 1 {
|
|
t.Fatalf("not sent: %+v, calls=%d", outcome, invites.calls)
|
|
}
|
|
}
|
|
|
|
func TestResendRefusesAMerchantWhoAlreadyHasAPassword(t *testing.T) {
|
|
// The line between a resend and a password reset.
|
|
//
|
|
// `SetInitialPassword` would refuse such a link anyway, so the merchant
|
|
// could come to no harm — but the operator would be told mail was sent, the
|
|
// merchant would follow a link that does nothing, and neither would know
|
|
// why. Refusing here names the real situation.
|
|
target := waiting()
|
|
target.IsSetUp = true
|
|
|
|
invites := &countingInvites{sent: true}
|
|
service := NewTenantService(&inviteRepo{target: target}, invites)
|
|
|
|
_, err := service.ResendInvite(1147)
|
|
if err == nil {
|
|
t.Fatal("re-invited an account that already has a password")
|
|
}
|
|
if invites.calls != 0 {
|
|
t.Fatal("a link was minted for an account that is already set up")
|
|
}
|
|
// Says what to do instead, because the merchant's actual problem is signing
|
|
// in rather than setting up.
|
|
if !strings.Contains(err.Error(), "sign-in") {
|
|
t.Fatalf("the refusal does not say what to do instead: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResendNamesTheBusinessItRefused(t *testing.T) {
|
|
// An operator working through a list needs to know which one, not that
|
|
// "an account" was already set up.
|
|
target := waiting()
|
|
target.IsSetUp = true
|
|
|
|
_, err := NewTenantService(&inviteRepo{target: target}, &countingInvites{}).ResendInvite(1147)
|
|
if err == nil || !strings.Contains(err.Error(), "R Mart") {
|
|
t.Fatalf("the refusal does not name the business: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResendPassesThroughALookupFailure(t *testing.T) {
|
|
// No such tenant, or one whose primary email matches no login — which
|
|
// happens when the address is changed on the tenant without the account
|
|
// being changed with it. The fix is to correct one of the two, so the
|
|
// message has to survive rather than become "could not resend".
|
|
repo := &inviteRepo{err: errors.New("tenant 1147 has no account matching its primary email address")}
|
|
invites := &countingInvites{}
|
|
|
|
_, err := NewTenantService(repo, invites).ResendInvite(1147)
|
|
if err == nil || !strings.Contains(err.Error(), "primary email") {
|
|
t.Fatalf("the lookup's reason was lost: %v", err)
|
|
}
|
|
if invites.calls != 0 {
|
|
t.Fatal("a link was minted for an account that could not be found")
|
|
}
|
|
}
|
|
|
|
func TestResendWithNoMailConfiguredSaysSoRatherThanFailing(t *testing.T) {
|
|
// Not an error: the tenant is fine and the server simply cannot send. The
|
|
// controller turns this into a refusal for the operator, with the variable
|
|
// named.
|
|
service := NewTenantService(&inviteRepo{target: waiting()}, nil)
|
|
|
|
outcome, err := service.ResendInvite(1147)
|
|
if err != nil {
|
|
t.Fatalf("unconfigured mail reported as an error: %v", err)
|
|
}
|
|
if outcome.Sent || !strings.Contains(outcome.Reason, "not configured") {
|
|
t.Fatalf("unhelpful outcome: %+v", outcome)
|
|
}
|
|
}
|
|
|
|
func TestResendReportsWhyTheMailWasRefused(t *testing.T) {
|
|
invites := &countingInvites{sent: false, reason: "mailbox full"}
|
|
service := NewTenantService(&inviteRepo{target: waiting()}, invites)
|
|
|
|
outcome, err := service.ResendInvite(1147)
|
|
if err != nil {
|
|
t.Fatalf("resend: %v", err)
|
|
}
|
|
if outcome.Sent || outcome.Reason != "mailbox full" {
|
|
t.Fatalf("the provider's reason was lost: %+v", outcome)
|
|
}
|
|
}
|
|
|
|
// Onboarding and resend share the invite path, so a nil mailer must be safe on
|
|
// both. This is the create side.
|
|
func TestOnboardingWithNoMailStillCreatesTheTenant(t *testing.T) {
|
|
_ = models.Tenants{}
|
|
_ = config.MailConfig{}
|
|
|
|
service := NewTenantService(&inviteRepo{target: waiting()}, nil)
|
|
outcome := service.(*tenantService).inviteFor(models.UserInfo{Userid: 904}, models.Tenants{})
|
|
|
|
if outcome.Sent {
|
|
t.Fatal("reported as sent with no invite service")
|
|
}
|
|
if outcome.Reason == "" {
|
|
t.Fatal("not sent, and no reason for the operator")
|
|
}
|
|
}
|