338 lines
12 KiB
Go
338 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"nearle/models"
|
|
"nearle/repositories"
|
|
)
|
|
|
|
/*
|
|
Every account that is created with no password gets invited.
|
|
|
|
── Why this file exists ─────────────────────────────────────────────────────
|
|
|
|
There are three ways a back-office login comes into being on this platform, and
|
|
all three write `password = ''`:
|
|
|
|
- `CreateTenantUser` — the merchant, at onboarding
|
|
- `CreateUser` / `CreateStaff` — a person added to the directory afterwards
|
|
- `CreateTenantLocation` — the login a branch spawns when no operator is named
|
|
|
|
Only the first was ever invited. That was survivable while the sign-in screen
|
|
carried a "set your password" form, because the other two could use it. That form
|
|
is gone — it was an account takeover, since the probe behind it answered any
|
|
email with the userid needed to claim the account — so an uninvited account is now
|
|
one nobody can ever sign in to. It is listed, it appears in every branch picker,
|
|
and the first person to find out is whoever is standing in the shop.
|
|
|
|
So these tests are about coverage of the three paths, not about the mail. What
|
|
the message says and when sending fails is `inviteService_test.go`.
|
|
*/
|
|
|
|
// countingInviter records who was invited. `countingInvites` in
|
|
// resendInvite_test.go counts calls and nothing else; this one keeps the
|
|
// arguments, because the whole question here is who got the mail.
|
|
type countingInviter struct {
|
|
calls int
|
|
userid int
|
|
tenantid int
|
|
email string
|
|
business string
|
|
sent bool
|
|
reason string
|
|
failEvery bool
|
|
}
|
|
|
|
func (c *countingInviter) Invite(userid, tenantid int, email, businessName string) (bool, string) {
|
|
c.calls++
|
|
c.userid, c.tenantid, c.email, c.business = userid, tenantid, email, businessName
|
|
if c.failEvery {
|
|
return false, c.reason
|
|
}
|
|
return c.sent, c.reason
|
|
}
|
|
|
|
/* ── A person added to the directory ─────────────────────────────────────── */
|
|
|
|
type stubUserRepo struct {
|
|
repositories.UserRepository
|
|
newid int
|
|
err error
|
|
}
|
|
|
|
func (r *stubUserRepo) CreateUser(models.User) (int, error) {
|
|
if r.err != nil {
|
|
return 0, r.err
|
|
}
|
|
return r.newid, nil
|
|
}
|
|
|
|
func (r *stubUserRepo) GetUserById(uid int) (models.UserInfo, error) {
|
|
return models.UserInfo{Userid: uid}, nil
|
|
}
|
|
|
|
func TestANewStaffAccountIsInvited(t *testing.T) {
|
|
invites := &countingInviter{sent: true}
|
|
service := NewUserService(&stubUserRepo{newid: 7781}, invites)
|
|
|
|
_, outcome, err := service.CreateUser(models.User{
|
|
Email: "meena@rmart.example", Tenantid: 1147,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
|
|
if !outcome.Sent {
|
|
t.Fatalf("hired and not invited: %+v", outcome)
|
|
}
|
|
if invites.userid != 7781 {
|
|
t.Errorf("invited user %d, want the one that was just created (7781)", invites.userid)
|
|
}
|
|
if invites.email != "meena@rmart.example" {
|
|
t.Errorf("invitation addressed to %q", invites.email)
|
|
}
|
|
if invites.tenantid != 1147 {
|
|
// The token carries the tenant, and the mail names the business. A zero
|
|
// here is an invitation from nobody, to an account belonging to nobody.
|
|
t.Errorf("invited against tenant %d, want 1147", invites.tenantid)
|
|
}
|
|
}
|
|
|
|
func TestAStaffAccountWithOnlyAnAuthnameIsStillInvited(t *testing.T) {
|
|
// `PrepareNewAccount` copies email → authname, not the other way round, so a
|
|
// caller that filled in only the sign-in name leaves `Email` empty. That is
|
|
// the same mailbox, and refusing to write to it would strand the person over
|
|
// which of two identical fields was filled in.
|
|
invites := &countingInviter{sent: true}
|
|
service := NewUserService(&stubUserRepo{newid: 7782}, invites)
|
|
|
|
if _, outcome, err := service.CreateUser(models.User{
|
|
Authname: "arun@rmart.example", Tenantid: 1147,
|
|
}); err != nil || !outcome.Sent {
|
|
t.Fatalf("not invited: outcome=%+v err=%v", outcome, err)
|
|
}
|
|
if invites.email != "arun@rmart.example" {
|
|
t.Errorf("invitation addressed to %q, want the authname", invites.email)
|
|
}
|
|
}
|
|
|
|
func TestHiringSucceedsWhenTheInvitationDoesNot(t *testing.T) {
|
|
// The person is hired either way. A mail relay that refuses the address must
|
|
// not undo a hire — the operator resends, or corrects the address.
|
|
invites := &countingInviter{failEvery: true, reason: "mailbox full"}
|
|
service := NewUserService(&stubUserRepo{newid: 7783}, invites)
|
|
|
|
info, outcome, err := service.CreateUser(models.User{Email: "raj@rmart.example"})
|
|
if err != nil {
|
|
t.Fatalf("a failed invitation failed the hire: %v", err)
|
|
}
|
|
if info.Userid != 7783 {
|
|
t.Fatalf("the account was not created: %+v", info)
|
|
}
|
|
if outcome.Sent || outcome.Reason != "mailbox full" {
|
|
t.Fatalf("the reason was lost: %+v", outcome)
|
|
}
|
|
}
|
|
|
|
func TestAFailedCreateIsNotInvited(t *testing.T) {
|
|
invites := &countingInviter{sent: true}
|
|
service := NewUserService(&stubUserRepo{err: errors.New("duplicate authname")}, invites)
|
|
|
|
if _, _, err := service.CreateUser(models.User{Email: "raj@rmart.example"}); err == nil {
|
|
t.Fatal("a failed create was reported as success")
|
|
}
|
|
if invites.calls != 0 {
|
|
t.Fatal("invited an account that was never created")
|
|
}
|
|
}
|
|
|
|
func TestStaffCreatedThroughTheTenantPathIsAlsoInvited(t *testing.T) {
|
|
// Two endpoints make back-office accounts — `users/create` and
|
|
// `tenants/createstaff` — and the console has used both. An invitation on one
|
|
// only would be a gap nobody could see from the screen they were using.
|
|
invites := &countingInviter{sent: true}
|
|
service := NewTenantService(&recordingTenantRepo{}, invites)
|
|
|
|
outcome, err := service.CreateStaff(models.User{
|
|
Email: "kavi@rmart.example", Tenantid: 1147,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateStaff: %v", err)
|
|
}
|
|
if !outcome.Sent || invites.userid != 5150 {
|
|
t.Fatalf("not invited, or the wrong account: outcome=%+v userid=%d", outcome, invites.userid)
|
|
}
|
|
}
|
|
|
|
/* ── The login a branch spawns ───────────────────────────────────────────── */
|
|
|
|
type branchRepo struct {
|
|
repositories.TenantRepository
|
|
spawned int
|
|
err error
|
|
}
|
|
|
|
func (r *branchRepo) CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, int, error) {
|
|
if r.err != nil {
|
|
return models.Tenantlocations{}, 0, r.err
|
|
}
|
|
data.Locationid = 4420
|
|
return data, r.spawned, nil
|
|
}
|
|
|
|
func TestABranchesOwnLoginIsInvited(t *testing.T) {
|
|
invites := &countingInviter{sent: true}
|
|
service := NewTenantService(&branchRepo{spawned: 9001}, invites)
|
|
|
|
resp := service.CreateTenantLocation(models.Tenantlocations{
|
|
Locationname: "R Mart Peelamedu", Email: "peelamedu@rmart.example",
|
|
Tenantid: 1147, Address: "100 Feet Road", City: "Coimbatore",
|
|
})
|
|
|
|
if resp["status"] != true {
|
|
t.Fatalf("branch not created: %+v", resp)
|
|
}
|
|
if resp["invited"] != true {
|
|
t.Fatalf("the branch's own login was not invited: %+v", resp)
|
|
}
|
|
if invites.userid != 9001 {
|
|
t.Errorf("invited user %d, want the spawned login (9001)", invites.userid)
|
|
}
|
|
if invites.email != "peelamedu@rmart.example" {
|
|
t.Errorf("invitation addressed to %q, want the branch's email", invites.email)
|
|
}
|
|
}
|
|
|
|
func TestABranchHandedToAnExistingPersonSendsNothing(t *testing.T) {
|
|
// `spawned: 0` is the repository saying it created no account, because an
|
|
// `operatorid` was named. That person had a login before this branch existed,
|
|
// and re-inviting them would be a password reset in a branch's clothing —
|
|
// the one thing this whole mechanism is built to not become.
|
|
invites := &countingInviter{sent: true}
|
|
service := NewTenantService(&branchRepo{spawned: 0}, invites)
|
|
|
|
resp := service.CreateTenantLocation(models.Tenantlocations{
|
|
Locationname: "R Mart Gandhipuram", Operatorid: 7781, Tenantid: 1147,
|
|
})
|
|
|
|
if resp["status"] != true {
|
|
t.Fatalf("branch not created: %+v", resp)
|
|
}
|
|
if invites.calls != 0 {
|
|
t.Fatal("re-invited an existing person because a branch was commissioned")
|
|
}
|
|
if resp["invited"] != false {
|
|
t.Errorf("invited should be false when there was nobody to invite: %+v", resp)
|
|
}
|
|
if reason, _ := resp["invitereason"].(string); reason != "" {
|
|
// Nothing went wrong, so nothing is explained. A reason here reads as a
|
|
// failure on a screen that is reporting a success.
|
|
t.Errorf("apologised for an invitation that was never owed: %q", reason)
|
|
}
|
|
}
|
|
|
|
func TestAFailedBranchIsNotInvited(t *testing.T) {
|
|
invites := &countingInviter{sent: true}
|
|
service := NewTenantService(&branchRepo{err: errors.New("no such tenant")}, invites)
|
|
|
|
resp := service.CreateTenantLocation(models.Tenantlocations{
|
|
Locationname: "R Mart Nowhere", Email: "nowhere@rmart.example",
|
|
})
|
|
|
|
if resp["status"] != false {
|
|
t.Fatalf("a failed create was reported as success: %+v", resp)
|
|
}
|
|
if invites.calls != 0 {
|
|
t.Fatal("invited a login for a branch that does not exist")
|
|
}
|
|
}
|
|
|
|
/* ── Resending to one named person ───────────────────────────────────────── */
|
|
|
|
type userTargetRepo struct {
|
|
repositories.TenantRepository
|
|
target repositories.InviteTarget
|
|
err error
|
|
asked int
|
|
}
|
|
|
|
func (r *userTargetRepo) InviteTargetForUser(userID int) (repositories.InviteTarget, error) {
|
|
r.asked = userID
|
|
if r.err != nil {
|
|
return repositories.InviteTarget{}, r.err
|
|
}
|
|
return r.target, nil
|
|
}
|
|
|
|
func TestResendReachesAStaffMemberByUserid(t *testing.T) {
|
|
// The owner is reachable by tenantid because there is one of them. Everybody
|
|
// else has to be named, and before this there was no way to reach them at
|
|
// all — the only repair was editing the database.
|
|
repo := &userTargetRepo{target: repositories.InviteTarget{
|
|
Userid: 7781, Email: "meena@rmart.example", Tenantname: "R Mart",
|
|
}}
|
|
invites := &countingInviter{sent: true}
|
|
|
|
outcome, err := NewTenantService(repo, invites).ResendInviteToUser(7781)
|
|
if err != nil {
|
|
t.Fatalf("ResendInviteToUser: %v", err)
|
|
}
|
|
if !outcome.Sent || repo.asked != 7781 || invites.userid != 7781 {
|
|
t.Fatalf("wrong person: outcome=%+v asked=%d invited=%d", outcome, repo.asked, invites.userid)
|
|
}
|
|
}
|
|
|
|
func TestResendToAUserRefusesOneWhoAlreadyHasAPassword(t *testing.T) {
|
|
// Same line as the tenant resend draws, and it has to be drawn here too:
|
|
// this endpoint takes any userid, so without the check it would re-issue a
|
|
// working password link for every account on the platform.
|
|
repo := &userTargetRepo{target: repositories.InviteTarget{
|
|
Userid: 7781, Email: "meena@rmart.example", Tenantname: "R Mart", IsSetUp: true,
|
|
}}
|
|
invites := &countingInviter{sent: true}
|
|
|
|
_, err := NewTenantService(repo, invites).ResendInviteToUser(7781)
|
|
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")
|
|
}
|
|
if !strings.Contains(err.Error(), "sign-in") {
|
|
t.Errorf("the refusal does not say what to do instead: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResendToAUserWithNoBusinessStillReadsSensibly(t *testing.T) {
|
|
// A back-office account with no tenant is a Nearle staff row, and one exists.
|
|
// The refusal interpolates the business name, so an empty one would read
|
|
// " has already set a password".
|
|
repo := &userTargetRepo{target: repositories.InviteTarget{
|
|
Userid: 12, Email: "ops@nearle.in", Tenantname: "", IsSetUp: true,
|
|
}}
|
|
|
|
_, err := NewTenantService(repo, &countingInviter{}).ResendInviteToUser(12)
|
|
if err == nil {
|
|
t.Fatal("re-invited an account that already has a password")
|
|
}
|
|
if strings.HasPrefix(err.Error(), " ") || strings.Contains(err.Error(), " ") {
|
|
t.Errorf("the refusal has a hole where the business name should be: %q", err)
|
|
}
|
|
}
|
|
|
|
func TestResendToAUserPassesThroughALookupFailure(t *testing.T) {
|
|
repo := &userTargetRepo{err: errors.New("user 99 is not a back-office account on this platform")}
|
|
invites := &countingInviter{}
|
|
|
|
_, err := NewTenantService(repo, invites).ResendInviteToUser(99)
|
|
if err == nil || !strings.Contains(err.Error(), "back-office") {
|
|
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")
|
|
}
|
|
}
|