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") } }