auto mail generation
This commit is contained in:
337
services/inviteEveryAccount_test.go
Normal file
337
services/inviteEveryAccount_test.go
Normal file
@@ -0,0 +1,337 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
151
services/inviteService.go
Normal file
151
services/inviteService.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nearle/config"
|
||||
"nearle/utils"
|
||||
)
|
||||
|
||||
// The invitation a newly onboarded merchant receives.
|
||||
//
|
||||
// ── Why onboarding does not fail when this does ─────────────────────────────
|
||||
//
|
||||
// `Invite` never returns an error to the onboarding path. A tenant that exists
|
||||
// and has not been emailed is recoverable — somebody presses resend — while a
|
||||
// tenant rolled back because a mail relay was slow is a business that was
|
||||
// onboarded, told it was onboarded, and is not in the system. The first is a
|
||||
// task; the second is a phone call nobody can explain.
|
||||
//
|
||||
// So a failure is logged loudly and reported as `false`, and the caller decides
|
||||
// what to tell the operator. The platform console shows "invitation not sent"
|
||||
// beside the tenant, which is the state somebody can act on.
|
||||
type InviteService interface {
|
||||
// Invite emails a first-password link. Reports whether it was sent, and
|
||||
// why not when it was not — a sentence for the operator, not an error.
|
||||
//
|
||||
// `businessName` may be empty: the name is then looked up from `tenantid`,
|
||||
// because most callers hold an account and a tenantid and nothing else about
|
||||
// the business. A caller that already has the name — onboarding, which was
|
||||
// handed it in the form — passes it and saves the query.
|
||||
Invite(userid, tenantid int, email, businessName string) (bool, string)
|
||||
}
|
||||
|
||||
// TenantNamer reads a business's name for the invitation's first line.
|
||||
//
|
||||
// A one-method interface rather than the whole tenant repository, because that
|
||||
// is all this needs and because it keeps `inviteService` testable without a
|
||||
// database. `repositories.TenantRepository` satisfies it.
|
||||
type TenantNamer interface {
|
||||
TenantNameByID(tenantID int) (string, error)
|
||||
}
|
||||
|
||||
type inviteService struct {
|
||||
mailer utils.Mailer
|
||||
cfg config.MailConfig
|
||||
// May be nil. The invitation then says "your business", which is worse copy
|
||||
// and a working link — never a reason not to send.
|
||||
names TenantNamer
|
||||
}
|
||||
|
||||
func NewInviteService(mailer utils.Mailer, cfg config.MailConfig, names TenantNamer) InviteService {
|
||||
return &inviteService{mailer: mailer, cfg: cfg, names: names}
|
||||
}
|
||||
|
||||
func (s *inviteService) Invite(userid, tenantid int, email, businessName string) (bool, string) {
|
||||
address := strings.TrimSpace(email)
|
||||
if address == "" {
|
||||
return false, "no email address on the account"
|
||||
}
|
||||
if s.mailer == nil {
|
||||
// Not a fault. A deployment with no mail configured still onboards; the
|
||||
// reason names the variable so it is fixable rather than mysterious.
|
||||
return false, s.cfg.Why()
|
||||
}
|
||||
|
||||
token, _, err := utils.MintInviteToken(
|
||||
utils.InviteClaims{Userid: userid, Tenantid: tenantid}, time.Now())
|
||||
if err != nil {
|
||||
// Only happens with no signing secret, which is already fatal at boot
|
||||
// in production — but an invitation with no token would be a link that
|
||||
// cannot work, and sending it would be worse than not sending.
|
||||
log.Printf("invite: could not sign an invitation for user %d: %v", userid, err)
|
||||
return false, "this server cannot sign an invitation"
|
||||
}
|
||||
|
||||
subject, body := inviteMessage(s.businessName(tenantid, businessName), s.cfg.InviteLink(token))
|
||||
|
||||
if err := s.mailer.Send(address, subject, body); err != nil {
|
||||
log.Printf("invite: could not email user %d at %s: %v", userid, address, err)
|
||||
return false, err.Error()
|
||||
}
|
||||
|
||||
log.Printf("invite: sent to user %d for tenant %d", userid, tenantid)
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// businessName is the name for the mail's first line.
|
||||
//
|
||||
// Looked up only when the caller did not have one. A failure is logged and
|
||||
// swallowed: the alternative is refusing to send somebody their only way into
|
||||
// their account because a name could not be read, and "your business has been
|
||||
// set up on Nearle" is a perfectly usable sentence.
|
||||
func (s *inviteService) businessName(tenantid int, given string) string {
|
||||
if name := strings.TrimSpace(given); name != "" {
|
||||
return name
|
||||
}
|
||||
if s.names == nil || tenantid <= 0 {
|
||||
return ""
|
||||
}
|
||||
name, err := s.names.TenantNameByID(tenantid)
|
||||
if err != nil {
|
||||
log.Printf("invite: could not read tenant %d's name: %v", tenantid, err)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
// inviteMessage is what the merchant reads.
|
||||
//
|
||||
// ── Why it says so little ───────────────────────────────────────────────────
|
||||
//
|
||||
// This is the first thing a new merchant receives from us and the only way into
|
||||
// their account, so it has one job: make the link obvious and make it credible.
|
||||
// Every extra paragraph is somewhere for the link to hide, and a mail full of
|
||||
// features reads like marketing — which is the thing people delete.
|
||||
//
|
||||
// It states who it is for and what it does, gives the link on its own line, and
|
||||
// says how long it lasts. The expiry is there because an invitation found three
|
||||
// weeks later needs to explain itself rather than look broken.
|
||||
//
|
||||
// Plain text, not HTML. A password link that arrives as an image-heavy template
|
||||
// is the shape of a phishing mail, and plain text renders identically
|
||||
// everywhere.
|
||||
func inviteMessage(businessName, link string) (subject, body string) {
|
||||
name := strings.TrimSpace(businessName)
|
||||
if name == "" {
|
||||
name = "your business"
|
||||
}
|
||||
|
||||
subject = "Set your Nearle password"
|
||||
|
||||
body = fmt.Sprintf(`%s has been set up on Nearle.
|
||||
|
||||
To finish, choose a password for your account:
|
||||
|
||||
%s
|
||||
|
||||
This link is for you alone and works once. It expires in 7 days — if it has,
|
||||
ask whoever set you up to send another.
|
||||
|
||||
If you were not expecting this, you can ignore it. Nothing happens until
|
||||
somebody uses the link.
|
||||
|
||||
— Nearle
|
||||
`, name, link)
|
||||
|
||||
return subject, body
|
||||
}
|
||||
241
services/inviteService_test.go
Normal file
241
services/inviteService_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nearle/config"
|
||||
)
|
||||
|
||||
/*
|
||||
The invitation a newly onboarded merchant receives.
|
||||
|
||||
It is the only way into their account, so the tests are about two things: that a
|
||||
failure to send never costs them the tenant, and that the message itself is one
|
||||
a person will act on rather than delete.
|
||||
*/
|
||||
|
||||
type recordingMailer struct {
|
||||
to, subject, body string
|
||||
refuse error
|
||||
sent int
|
||||
}
|
||||
|
||||
func (m *recordingMailer) Send(to, subject, body string) error {
|
||||
if m.refuse != nil {
|
||||
return m.refuse
|
||||
}
|
||||
m.to, m.subject, m.body = to, subject, body
|
||||
m.sent++
|
||||
return nil
|
||||
}
|
||||
|
||||
func workingMail() config.MailConfig {
|
||||
return config.MailConfig{
|
||||
Host: "smtp.example.com", Port: 587,
|
||||
FromAddress: "noreply@nearledaily.com", FromName: "Nearle",
|
||||
ConsoleURL: "https://app.nearledaily.com",
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInvitationCarriesALinkAndNothingElseIdentifying(t *testing.T) {
|
||||
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
|
||||
|
||||
mailer := &recordingMailer{}
|
||||
sent, reason := NewInviteService(mailer, workingMail(), nil).
|
||||
Invite(904, 1147, "owner@rmart.example", "R Mart")
|
||||
|
||||
if !sent {
|
||||
t.Fatalf("not sent: %s", reason)
|
||||
}
|
||||
if mailer.to != "owner@rmart.example" {
|
||||
t.Fatalf("addressed to %q", mailer.to)
|
||||
}
|
||||
if !strings.Contains(mailer.body, "https://app.nearledaily.com/set-password?t=i1.") {
|
||||
t.Fatalf("no invitation link in the body:\n%s", mailer.body)
|
||||
}
|
||||
|
||||
// The token is the whole credential, so it is the only thing in the URL.
|
||||
// An email address or a userid in a query string ends up in server logs,
|
||||
// browser history and whatever proxy sits between — which would put both
|
||||
// halves of an account somewhere neither belongs.
|
||||
link := mailer.body[strings.Index(mailer.body, "https://"):]
|
||||
link = strings.Fields(link)[0]
|
||||
if strings.Contains(link, "@") || strings.Contains(link, "904") {
|
||||
t.Fatalf("the link identifies the account beyond the token: %s", link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheInvitationNamesTheBusinessAndTheExpiry(t *testing.T) {
|
||||
// Named, because this arrives unannounced at an address the merchant gave
|
||||
// during a sales conversation weeks earlier. "Your business has been set
|
||||
// up" reads like a phishing template; their own name does not.
|
||||
//
|
||||
// The expiry is there so an invitation found three weeks later explains
|
||||
// itself rather than looking broken.
|
||||
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
|
||||
|
||||
mailer := &recordingMailer{}
|
||||
NewInviteService(mailer, workingMail(), nil).Invite(904, 1147, "owner@rmart.example", "R Mart")
|
||||
|
||||
if !strings.Contains(mailer.body, "R Mart") {
|
||||
t.Fatalf("the business is not named:\n%s", mailer.body)
|
||||
}
|
||||
if !strings.Contains(mailer.body, "7 days") {
|
||||
t.Fatalf("the expiry is not stated:\n%s", mailer.body)
|
||||
}
|
||||
if strings.TrimSpace(mailer.subject) == "" {
|
||||
t.Fatal("no subject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnnamedBusinessStillReadsAsASentence(t *testing.T) {
|
||||
// `tenantname` is not enforced anywhere upstream, and " has been set up on
|
||||
// Nearle" is the kind of thing that ships.
|
||||
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
|
||||
|
||||
mailer := &recordingMailer{}
|
||||
NewInviteService(mailer, workingMail(), nil).Invite(904, 1147, "owner@rmart.example", " ")
|
||||
|
||||
if strings.Contains(mailer.body, " has been set up") {
|
||||
t.Fatalf("the blank name left a gap:\n%s", mailer.body)
|
||||
}
|
||||
if !strings.Contains(mailer.body, "your business") {
|
||||
t.Fatalf("no fallback for an unnamed business:\n%s", mailer.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoMailerMeansNotSentRatherThanAPanic(t *testing.T) {
|
||||
// The ordinary state of a deployment that has not configured mail. It must
|
||||
// report, not crash and not pretend.
|
||||
sent, reason := NewInviteService(nil, config.MailConfig{}, nil).
|
||||
Invite(904, 1147, "owner@rmart.example", "R Mart")
|
||||
|
||||
if sent {
|
||||
t.Fatal("reported as sent with no mailer")
|
||||
}
|
||||
if !strings.Contains(reason, "MAIL_HOST") {
|
||||
t.Fatalf("the reason does not name what is missing: %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusedSendIsReportedNotSwallowed(t *testing.T) {
|
||||
// The operator has to learn that the merchant was not emailed, or the
|
||||
// merchant waits for a link that never comes and nobody knows.
|
||||
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
|
||||
|
||||
mailer := &recordingMailer{refuse: errors.New("mailbox full")}
|
||||
sent, reason := NewInviteService(mailer, workingMail(), nil).
|
||||
Invite(904, 1147, "owner@rmart.example", "R Mart")
|
||||
|
||||
if sent {
|
||||
t.Fatal("a refused send reported as sent")
|
||||
}
|
||||
if !strings.Contains(reason, "mailbox full") {
|
||||
t.Fatalf("the provider's reason was lost: %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAccountWithNoEmailIsNotInvited(t *testing.T) {
|
||||
// `primaryemail` is not enforced at creation. Worth reporting rather than
|
||||
// handing an empty address to the relay and reading its refusal instead.
|
||||
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
|
||||
|
||||
mailer := &recordingMailer{}
|
||||
sent, reason := NewInviteService(mailer, workingMail(), nil).Invite(904, 1147, " ", "R Mart")
|
||||
|
||||
if sent || mailer.sent != 0 {
|
||||
t.Fatal("an invitation was sent to nobody")
|
||||
}
|
||||
if !strings.Contains(reason, "no email") {
|
||||
t.Fatalf("unhelpful reason: %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSigningSecretMeansNoInvitationRatherThanADeadLink(t *testing.T) {
|
||||
// Sending a link that cannot work is worse than not sending: the merchant
|
||||
// tries it, it fails, and the failure looks like the product.
|
||||
t.Setenv("POS_TOKEN_SECRET", "")
|
||||
t.Setenv("JWT_SECRET_KEY", "")
|
||||
|
||||
mailer := &recordingMailer{}
|
||||
sent, _ := NewInviteService(mailer, workingMail(), nil).
|
||||
Invite(904, 1147, "owner@rmart.example", "R Mart")
|
||||
|
||||
if sent || mailer.sent != 0 {
|
||||
t.Fatal("an invitation went out with no signing secret")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Whose name is on the mail ───────────────────────────────────────────── */
|
||||
|
||||
type stubNamer struct {
|
||||
name string
|
||||
err error
|
||||
asked int
|
||||
}
|
||||
|
||||
func (s *stubNamer) TenantNameByID(tenantID int) (string, error) {
|
||||
s.asked = tenantID
|
||||
return s.name, s.err
|
||||
}
|
||||
|
||||
func TestTheBusinessNameIsLookedUpWhenTheCallerHasNone(t *testing.T) {
|
||||
// A staff row arrives with a tenantid and nothing else about the business, so
|
||||
// the caller cannot name it. Without the lookup every invitation but the
|
||||
// merchant's own would open "your business has been set up on Nearle".
|
||||
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
|
||||
|
||||
mailer := &recordingMailer{}
|
||||
namer := &stubNamer{name: "R Mart"}
|
||||
|
||||
if sent, reason := NewInviteService(mailer, workingMail(), namer).
|
||||
Invite(7781, 1147, "meena@rmart.example", ""); !sent {
|
||||
t.Fatalf("not sent: %s", reason)
|
||||
}
|
||||
|
||||
if namer.asked != 1147 {
|
||||
t.Errorf("looked up tenant %d, want 1147", namer.asked)
|
||||
}
|
||||
if !strings.Contains(mailer.body, "R Mart") {
|
||||
t.Errorf("the mail does not name the business:\n%s", mailer.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACallerThatKnowsTheNameIsNotMadeToLookItUp(t *testing.T) {
|
||||
// Onboarding was handed the name in the form. A query to learn something the
|
||||
// caller already holds is a round trip inside a request somebody is waiting
|
||||
// on, for a guaranteed identical answer.
|
||||
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
|
||||
|
||||
namer := &stubNamer{name: "Should Not Be Read"}
|
||||
NewInviteService(&recordingMailer{}, workingMail(), namer).
|
||||
Invite(904, 1147, "owner@rmart.example", "R Mart")
|
||||
|
||||
if namer.asked != 0 {
|
||||
t.Error("looked the name up although the caller passed one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnreadableBusinessNameStillSendsTheInvitation(t *testing.T) {
|
||||
// The name is one word in the first line. The link is the person's only way
|
||||
// into their account, and refusing to send it over a failed lookup would
|
||||
// trade a worse sentence for somebody locked out.
|
||||
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
|
||||
|
||||
mailer := &recordingMailer{}
|
||||
namer := &stubNamer{err: errors.New("connection reset")}
|
||||
|
||||
sent, reason := NewInviteService(mailer, workingMail(), namer).
|
||||
Invite(7781, 1147, "meena@rmart.example", "")
|
||||
if !sent {
|
||||
t.Fatalf("a failed name lookup stopped the invitation: %s", reason)
|
||||
}
|
||||
if !strings.Contains(mailer.body, "your business") {
|
||||
t.Errorf("no fallback for the missing name:\n%s", mailer.body)
|
||||
}
|
||||
if !strings.Contains(mailer.body, "set-password?t=") {
|
||||
t.Errorf("the link is missing:\n%s", mailer.body)
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func (r *recordingUserRepo) GetUserById(uid int) (models.UserInfo, error) {
|
||||
|
||||
func TestCreateUserActuallyPreparesTheAccount(t *testing.T) {
|
||||
repo := &recordingUserRepo{}
|
||||
if _, err := NewUserService(repo).CreateUser(models.User{
|
||||
if _, _, err := NewUserService(repo, nil).CreateUser(models.User{
|
||||
Email: "thiruomart@gmail.com",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
@@ -114,16 +114,16 @@ type recordingTenantRepo struct {
|
||||
created models.User
|
||||
}
|
||||
|
||||
func (r *recordingTenantRepo) CreateStaff(user models.User) error {
|
||||
func (r *recordingTenantRepo) CreateStaff(user models.User) (int, error) {
|
||||
r.created = user
|
||||
return nil
|
||||
return 5150, nil
|
||||
}
|
||||
|
||||
// The other creation path. Both make back-office accounts, so both have to
|
||||
// prepare them — and only one of them did.
|
||||
func TestCreateStaffActuallyPreparesTheAccount(t *testing.T) {
|
||||
repo := &recordingTenantRepo{}
|
||||
if err := NewTenantService(repo).CreateStaff(models.User{Email: "suriya@example.com"}); err != nil {
|
||||
if _, err := NewTenantService(repo, nil).CreateStaff(models.User{Email: "suriya@example.com"}); err != nil {
|
||||
t.Fatalf("CreateStaff: %v", err)
|
||||
}
|
||||
if repo.created.Authname != "suriya@example.com" || repo.created.Configid != ConsoleConfigID {
|
||||
|
||||
168
services/resendInvite_test.go
Normal file
168
services/resendInvite_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -25,22 +26,43 @@ type TenantService interface {
|
||||
UpdateTenantProfile(tenantID int, fields map[string]any) error
|
||||
UpdateOwnProfile(userID, tenantID int, fields map[string]any) error
|
||||
GetStaffs(tid int) ([]models.StaffInfo, error)
|
||||
CreateStaff(user models.User) error
|
||||
// Adds a back-office person and emails their first-password invitation.
|
||||
//
|
||||
// Same shape as `CreateTenantUser`, and for the same reason: the account is
|
||||
// created with no password, and whether the mail left is a separate fact the
|
||||
// console has to show. A failure to send is not a failure to hire.
|
||||
CreateStaff(user models.User) (InviteOutcome, error)
|
||||
AssignStaffToBranch(tenantID, userID, locationID int) error
|
||||
UpdateStaff(user models.User) error
|
||||
CreateTenantLocation(data models.Tenantlocations) map[string]interface{}
|
||||
UpdateTenantLocation(data models.Tenantlocations) map[string]interface{}
|
||||
CreateTenantUser(data models.Tenants) (models.UserInfo, error)
|
||||
// Onboards a merchant and emails their first-password invitation.
|
||||
//
|
||||
// The outcome is returned rather than stashed on the service: it belongs to
|
||||
// one call, and a field would race between two operators onboarding at the
|
||||
// same moment.
|
||||
CreateTenantUser(data models.Tenants) (models.UserInfo, InviteOutcome, error)
|
||||
// ResendInvite re-issues a first-password link for a tenant that never got
|
||||
// one, or whose invitation expired.
|
||||
ResendInvite(tenantID int) (InviteOutcome, error)
|
||||
// ResendInviteToUser does the same for one named account — a staff member or
|
||||
// a branch's own login, neither of which is reachable by tenantid because a
|
||||
// business has many of them.
|
||||
ResendInviteToUser(userID int) (InviteOutcome, error)
|
||||
GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error)
|
||||
GetTenantByKeyword(keyword string) ([]models.TenantSearch, error)
|
||||
}
|
||||
|
||||
type tenantService struct {
|
||||
repo repositories.TenantRepository
|
||||
// May be nil. A deployment with no mail configured still onboards tenants
|
||||
// — the merchant is told by whoever set them up — and the outcome says so
|
||||
// rather than the creation failing.
|
||||
invites InviteService
|
||||
}
|
||||
|
||||
func NewTenantService(repo repositories.TenantRepository) TenantService {
|
||||
return &tenantService{repo: repo}
|
||||
func NewTenantService(repo repositories.TenantRepository, invites InviteService) TenantService {
|
||||
return &tenantService{repo: repo, invites: invites}
|
||||
}
|
||||
|
||||
func (s *tenantService) SearchTenant(status, keyword string) ([]models.Tenantinfo, error) {
|
||||
@@ -105,11 +127,53 @@ func (s *tenantService) GetStaffs(tid int) ([]models.StaffInfo, error) {
|
||||
return s.repo.GetStaffs(tid)
|
||||
}
|
||||
|
||||
func (s *tenantService) CreateStaff(user models.User) error {
|
||||
func (s *tenantService) CreateStaff(user models.User) (InviteOutcome, error) {
|
||||
// Same two fields, same reason: without an authname and a console configid
|
||||
// the account is created, listed, and refused at the login screen. This path
|
||||
// and users/create both make back-office accounts, so both need it.
|
||||
return s.repo.CreateStaff(PrepareNewAccount(user))
|
||||
ready := PrepareNewAccount(user)
|
||||
|
||||
userid, err := s.repo.CreateStaff(ready)
|
||||
if err != nil {
|
||||
return InviteOutcome{}, err
|
||||
}
|
||||
|
||||
// The invitation, for the same reason onboarding sends one: this account is
|
||||
// created with no password, and the sign-in screen no longer offers to set
|
||||
// one. Without the mail the person is added to the directory, appears in
|
||||
// every branch picker, and cannot sign in — and nothing anywhere would say
|
||||
// so. That was true for a while, and this is the fix.
|
||||
//
|
||||
// After the write and outside it, like the tenant's. A person who exists and
|
||||
// was not emailed is a resend; a person rolled back by a slow mail relay is
|
||||
// somebody the manager was told they had hired.
|
||||
return s.inviteAccount(userid, ready.Tenantid, ready.Email, ready.Authname), nil
|
||||
}
|
||||
|
||||
// inviteAccount emails one newly created back-office account.
|
||||
//
|
||||
// The business name is left to the invite service, which looks it up from the
|
||||
// tenantid: a staff row arrives with an id and nothing else about the business,
|
||||
// and every caller doing that lookup itself would be the same query written four
|
||||
// times.
|
||||
func (s *tenantService) inviteAccount(userid, tenantid int, email, authname string) InviteOutcome {
|
||||
if s.invites == nil {
|
||||
return InviteOutcome{Reason: "invitations are not configured on this server"}
|
||||
}
|
||||
if userid <= 0 {
|
||||
return InviteOutcome{Reason: "the new account could not be read back to invite it"}
|
||||
}
|
||||
|
||||
address := strings.TrimSpace(email)
|
||||
if address == "" {
|
||||
// The authname IS the email on every back-office account — `users/create`
|
||||
// and `createstaff` both copy one to the other — so this is a fallback
|
||||
// for a caller that filled in only one of the two, not a second address.
|
||||
address = strings.TrimSpace(authname)
|
||||
}
|
||||
|
||||
sent, reason := s.invites.Invite(userid, tenantid, address, "")
|
||||
return InviteOutcome{Sent: sent, Reason: reason}
|
||||
}
|
||||
|
||||
func (s *tenantService) UpdateStaff(user models.User) error {
|
||||
@@ -125,7 +189,7 @@ func (s *tenantService) CreateTenantLocation(data models.Tenantlocations) map[st
|
||||
data.Address, data.Suburb, data.City, data.State, data.Postcode)
|
||||
}
|
||||
|
||||
created, err := s.repo.CreateTenantLocation(data)
|
||||
created, spawnedUserid, err := s.repo.CreateTenantLocation(data)
|
||||
if err != nil {
|
||||
return map[string]interface{}{
|
||||
"code": http.StatusConflict,
|
||||
@@ -134,6 +198,17 @@ func (s *tenantService) CreateTenantLocation(data models.Tenantlocations) map[st
|
||||
}
|
||||
}
|
||||
|
||||
// A branch that spawned its own login needs that login invited — it is
|
||||
// created with no password, and the invitation is the only way to set one.
|
||||
// `spawnedUserid` is 0 when an existing person was named instead, and there
|
||||
// is deliberately nothing to send then: they had an account before this
|
||||
// branch existed, and re-inviting somebody who may already have a password
|
||||
// would be a password reset wearing a branch's clothes.
|
||||
invite := InviteOutcome{}
|
||||
if spawnedUserid > 0 {
|
||||
invite = s.inviteAccount(spawnedUserid, data.Tenantid, data.Email, data.Email)
|
||||
}
|
||||
|
||||
// "details" carries back the DB-assigned locationid so the frontend can
|
||||
// build the store's QR code (tenantid+locationid) immediately after
|
||||
// onboarding, instead of having to look the new location up separately.
|
||||
@@ -142,6 +217,21 @@ func (s *tenantService) CreateTenantLocation(data models.Tenantlocations) map[st
|
||||
"message": "Tenant Location Successfully Created",
|
||||
"status": true,
|
||||
"details": created,
|
||||
// Beside "details" for the same reason it is on the tenant create: the
|
||||
// branch exists either way, and whether its operator was emailed is a
|
||||
// separate fact the console has to be able to show.
|
||||
"invited": invite.Sent,
|
||||
// Omitted when it sent, and when there was nobody to send to — a branch
|
||||
// handed to an existing person has no invitation to report, and an
|
||||
// apology there would read as a failure.
|
||||
"invitereason": invite.Reason,
|
||||
// Who to resend to, when it did not go. 0 when no login was spawned.
|
||||
//
|
||||
// The console cannot work this out: `details` is the tenantlocations row,
|
||||
// and the account lives in `app_users`. Without this the only route to a
|
||||
// resend is finding the right row in the people list by eye, on a screen
|
||||
// that has just told somebody the mail failed.
|
||||
"inviteuserid": spawnedUserid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,11 +252,11 @@ func (s *tenantService) UpdateTenantLocation(data models.Tenantlocations) map[st
|
||||
}
|
||||
}
|
||||
|
||||
func (s *tenantService) CreateTenantUser(data models.Tenants) (models.UserInfo, error) {
|
||||
func (s *tenantService) CreateTenantUser(data models.Tenants) (models.UserInfo, InviteOutcome, error) {
|
||||
// ✅ Check if tenant already exists
|
||||
exists := s.repo.CheckTenantByNo(data.Primarycontact)
|
||||
if exists != 0 {
|
||||
return models.UserInfo{}, errors.New("Tenant Already Exists")
|
||||
return models.UserInfo{}, InviteOutcome{}, errors.New("Tenant Already Exists")
|
||||
}
|
||||
|
||||
// Coordinates from the address, for the tenant and its primary outlet.
|
||||
@@ -184,12 +274,61 @@ func (s *tenantService) CreateTenantUser(data models.Tenants) (models.UserInfo,
|
||||
// ✅ Create Tenant User
|
||||
status, err := s.repo.CreateTenantUser(data)
|
||||
if err != nil || !status {
|
||||
return models.UserInfo{}, err
|
||||
return models.UserInfo{}, InviteOutcome{}, err
|
||||
}
|
||||
|
||||
// ✅ Get user details by contact number
|
||||
result := s.repo.GetUserByNo(data.Primarycontact)
|
||||
return result, nil
|
||||
|
||||
// The invitation, sent after everything above is committed and never inside
|
||||
// it. `CreateTenantUser` in the repository runs a transaction; this does not
|
||||
// join it.
|
||||
//
|
||||
// A tenant that exists and has not been emailed is recoverable — somebody
|
||||
// presses resend. A tenant rolled back because a mail relay was slow is a
|
||||
// business that was onboarded, told it was onboarded, and is not in the
|
||||
// system. The first is a task; the second is a phone call nobody can
|
||||
// explain.
|
||||
//
|
||||
// The account being invited is the one the repository just wrote: primary
|
||||
// email as the authname, roleid 3, and no password. That empty password is
|
||||
// what makes the invitation the only way in, and what `SetInitialPassword`
|
||||
// re-checks before it writes.
|
||||
return result, s.inviteFor(result, data), nil
|
||||
}
|
||||
|
||||
// InviteOutcome is what the operator is told about the invitation.
|
||||
//
|
||||
// Its own type rather than a bool, because "not sent" is only useful with the
|
||||
// reason attached: somebody who sees a tenant created and no mail sent needs to
|
||||
// know whether to correct an address or set a variable.
|
||||
//
|
||||
// Returned rather than stashed on the service. The first version of this kept
|
||||
// it in a field for the controller to read afterwards, which races — the
|
||||
// service is one shared instance, and two operators onboarding at the same
|
||||
// moment would each read the other's result. A value belonging to one call
|
||||
// travels with that call.
|
||||
type InviteOutcome struct {
|
||||
Sent bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// inviteFor emails the new merchant, and says what happened.
|
||||
//
|
||||
// Never returns an error: the outcome is for the operator who onboarded them,
|
||||
// not something for the caller to fail on.
|
||||
func (s *tenantService) inviteFor(user models.UserInfo, data models.Tenants) InviteOutcome {
|
||||
if s.invites == nil {
|
||||
return InviteOutcome{Reason: "invitations are not configured on this server"}
|
||||
}
|
||||
if user.Userid <= 0 {
|
||||
// The account was written but could not be read back, so there is
|
||||
// nobody to address. Worth saying rather than silently not sending.
|
||||
return InviteOutcome{Reason: "the new account could not be read back to invite it"}
|
||||
}
|
||||
|
||||
sent, reason := s.invites.Invite(user.Userid, user.Tenantid, data.Primaryemail, data.Tenantname)
|
||||
return InviteOutcome{Sent: sent, Reason: reason}
|
||||
}
|
||||
|
||||
func (s *tenantService) GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error) {
|
||||
@@ -268,3 +407,63 @@ func (s *tenantService) UpdateOwnProfile(userID, tenantID int, fields map[string
|
||||
func (s *tenantService) AssignPartner(tenantID, partnerID int) error {
|
||||
return s.repo.AssignPartner(tenantID, partnerID)
|
||||
}
|
||||
|
||||
// ResendInvite emails a fresh first-password link to a tenant's admin.
|
||||
//
|
||||
// ── Why it refuses an account that is already set up ────────────────────────
|
||||
//
|
||||
// `SetInitialPassword` would refuse such a link anyway, so the merchant could
|
||||
// come to no harm — but the operator would be told the invitation was sent, the
|
||||
// merchant would follow a link that does not work, and nobody would understand
|
||||
// why. Refusing here names the real situation: they already have a password,
|
||||
// and what they need is help signing in.
|
||||
//
|
||||
// It also keeps this from quietly becoming a password reset. Nothing on this
|
||||
// backend verifies identity well enough to support one, and an endpoint that
|
||||
// re-issues a working link for any account is that, whatever it is called.
|
||||
func (s *tenantService) ResendInvite(tenantID int) (InviteOutcome, error) {
|
||||
target, err := s.repo.PrimaryAdminForTenant(tenantID)
|
||||
if err != nil {
|
||||
return InviteOutcome{}, err
|
||||
}
|
||||
return s.resendTo(target, tenantID)
|
||||
}
|
||||
|
||||
// ResendInviteToUser re-invites one named account.
|
||||
//
|
||||
// The owner is reachable by tenantid because there is exactly one of them. Staff
|
||||
// added after onboarding, and the login every branch spawns, are not — a business
|
||||
// has many, and all of them are created with no password. So an operator chasing
|
||||
// a branch manager who never received their mail names the person.
|
||||
//
|
||||
// Same refusals as above, for the same reason: this must not become a password
|
||||
// reset for anybody whose userid can be found.
|
||||
func (s *tenantService) ResendInviteToUser(userID int) (InviteOutcome, error) {
|
||||
target, err := s.repo.InviteTargetForUser(userID)
|
||||
if err != nil {
|
||||
return InviteOutcome{}, err
|
||||
}
|
||||
return s.resendTo(target, 0)
|
||||
}
|
||||
|
||||
// resendTo is the half the two resends share.
|
||||
//
|
||||
// `tenantID` is passed in rather than read off the target because the token's
|
||||
// claim should carry the tenant the CALLER asked about; a staff resend has no
|
||||
// tenant in hand and 0 is honest about that.
|
||||
func (s *tenantService) resendTo(target repositories.InviteTarget, tenantID int) (InviteOutcome, error) {
|
||||
if target.IsSetUp {
|
||||
who := strings.TrimSpace(target.Tenantname)
|
||||
if who == "" {
|
||||
who = "That account"
|
||||
}
|
||||
return InviteOutcome{}, fmt.Errorf(
|
||||
"%s has already set a password — send them to the sign-in page instead", who)
|
||||
}
|
||||
if s.invites == nil {
|
||||
return InviteOutcome{Reason: "invitations are not configured on this server"}, nil
|
||||
}
|
||||
|
||||
sent, reason := s.invites.Invite(target.Userid, tenantID, target.Email, target.Tenantname)
|
||||
return InviteOutcome{Sent: sent, Reason: reason}, nil
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func (r *loginRepo) GetTenantUserById(uid int) models.TenantUserInfo {
|
||||
|
||||
func TestADatabaseFailureIsNotAnInvalidEmail(t *testing.T) {
|
||||
repo := &loginRepo{err: errors.New("dial tcp 10.0.0.5:5433: connection refused")}
|
||||
svc := NewUserService(repo)
|
||||
svc := NewUserService(repo, nil)
|
||||
user := models.User{Authname: "owner@shop.example", Password: "pw", Configid: 1}
|
||||
|
||||
t.Run("app login", func(t *testing.T) {
|
||||
@@ -81,7 +81,7 @@ func TestADatabaseFailureIsNotAnInvalidEmail(t *testing.T) {
|
||||
// registered" from every other failure, so it must survive the refactor.
|
||||
func TestNobodyMatchingIsStillInvalidEmail(t *testing.T) {
|
||||
repo := &loginRepo{} // uid 0, no error: the query ran and found no row
|
||||
svc := NewUserService(repo)
|
||||
svc := NewUserService(repo, nil)
|
||||
user := models.User{Authname: "nobody@shop.example", Password: "pw", Configid: 1}
|
||||
|
||||
_, resp, err := svc.AppLogin(user)
|
||||
@@ -97,7 +97,7 @@ func TestNobodyMatchingIsStillInvalidEmail(t *testing.T) {
|
||||
|
||||
func TestLookupPrefersAuthnameAndFallsBackToContactNo(t *testing.T) {
|
||||
repo := &loginRepo{uid: 7, password: "pw", status: "Active"}
|
||||
svc := NewUserService(repo)
|
||||
svc := NewUserService(repo, nil)
|
||||
|
||||
svc.AppLogin(models.User{Authname: "owner@shop.example", Contactno: "9999999999", Password: "pw", Configid: 1})
|
||||
if repo.field != "authname" || repo.value != "owner@shop.example" || repo.configid != 1 {
|
||||
@@ -112,7 +112,7 @@ func TestLookupPrefersAuthnameAndFallsBackToContactNo(t *testing.T) {
|
||||
|
||||
func TestNeitherIdentifierIsRefusedBeforeTheLookup(t *testing.T) {
|
||||
repo := &loginRepo{err: errors.New("must not be called")}
|
||||
svc := NewUserService(repo)
|
||||
svc := NewUserService(repo, nil)
|
||||
|
||||
_, resp, err := svc.AppLogin(models.User{Password: "pw", Configid: 1})
|
||||
if err == nil || resp["code"] != 400 {
|
||||
@@ -125,7 +125,7 @@ func TestNeitherIdentifierIsRefusedBeforeTheLookup(t *testing.T) {
|
||||
|
||||
func TestAMatchedAccountStillSignsIn(t *testing.T) {
|
||||
repo := &loginRepo{uid: 42, password: "secret", status: "Active", roleid: 2}
|
||||
svc := NewUserService(repo)
|
||||
svc := NewUserService(repo, nil)
|
||||
|
||||
info, resp, err := svc.AppLogin(models.User{Authname: "owner@shop.example", Password: "secret", Configid: 1})
|
||||
if err != nil || resp["code"] != 200 || info.Userid != 42 {
|
||||
|
||||
@@ -63,17 +63,25 @@ type UserService interface {
|
||||
// the repository for what makes that safe.
|
||||
SetInitialPassword(userid int, password string) error
|
||||
AppLogin(user models.User) (models.TenantUserInfo, fiber.Map, error)
|
||||
CreateUser(user models.User) (models.UserInfo, error)
|
||||
// Creates a back-office account and emails its first-password invitation.
|
||||
//
|
||||
// The outcome travels beside the user rather than as an error: the person is
|
||||
// hired either way, and whether the mail left is something the console shows
|
||||
// so somebody can resend it.
|
||||
CreateUser(user models.User) (models.UserInfo, InviteOutcome, error)
|
||||
TenantWebLogin(user models.User) (models.TenantUserInfo, map[string]interface{})
|
||||
DeleteUser(userid int) error
|
||||
}
|
||||
|
||||
type userService struct {
|
||||
repo repositories.UserRepository
|
||||
// May be nil, like the tenant service's. A deployment with no mail still
|
||||
// creates accounts; the outcome names the missing variable.
|
||||
invites InviteService
|
||||
}
|
||||
|
||||
func NewUserService(repo repositories.UserRepository) UserService {
|
||||
return &userService{repo: repo}
|
||||
func NewUserService(repo repositories.UserRepository, invites InviteService) UserService {
|
||||
return &userService{repo: repo, invites: invites}
|
||||
}
|
||||
|
||||
func (s *userService) GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error) {
|
||||
@@ -162,16 +170,35 @@ func (s *userService) AppLogin(user models.User) (models.TenantUserInfo, fiber.M
|
||||
return models.TenantUserInfo{}, resp, errors.New("inactive account")
|
||||
}
|
||||
|
||||
// No password set
|
||||
// No password set.
|
||||
//
|
||||
// ── The userid used to be in here, and that was the whole exploit ───────
|
||||
//
|
||||
// This branch is reached by a POST carrying an email and NO password, so
|
||||
// anyone could ask it about any account. It answered with the userid, and
|
||||
// `setpassword` then took a bare userid — so the recipe was: read a
|
||||
// merchant's primary email off their shopfront, POST it here, receive their
|
||||
// userid, set their password, own the business's admin account. No guessing
|
||||
// at any step.
|
||||
//
|
||||
// `setpassword` now requires a signed invitation, so the userid alone is no
|
||||
// longer a way in. It is still removed, because handing it out told an
|
||||
// unauthenticated caller which businesses exist and which have never been
|
||||
// set up — a list worth having if you are the one sending the phishing
|
||||
// email that arrives before the real invitation does.
|
||||
//
|
||||
// The message is kept deliberately vague for the same reason. "Please set
|
||||
// up a password" invited the caller to do exactly that; this says where the
|
||||
// link comes from instead, which is true for the person who belongs here
|
||||
// and useless to anyone else.
|
||||
if strings.TrimSpace(dbPassword) == "" {
|
||||
resp := fiber.Map{
|
||||
"status": true,
|
||||
"code": 409,
|
||||
"message": "Please setup a password.",
|
||||
"message": "This account has not been set up yet. Use the invitation link that was emailed to you.",
|
||||
"tenantform": true,
|
||||
"details": fiber.Map{
|
||||
"userid": uid,
|
||||
"setup": true,
|
||||
"setup": true,
|
||||
},
|
||||
}
|
||||
return models.TenantUserInfo{}, resp, nil
|
||||
@@ -231,7 +258,7 @@ func (s *userService) AppLogin(user models.User) (models.TenantUserInfo, fiber.M
|
||||
return info, resp, nil
|
||||
}
|
||||
|
||||
func (s *userService) CreateUser(user models.User) (models.UserInfo, error) {
|
||||
func (s *userService) CreateUser(user models.User) (models.UserInfo, InviteOutcome, error) {
|
||||
// Without an authname and a console configid the account is created,
|
||||
// listed, and then refused at the login screen: `weblogin` matches
|
||||
// `WHERE authname = ? AND configid = ?` and never looks at the email
|
||||
@@ -241,16 +268,53 @@ func (s *userService) CreateUser(user models.User) (models.UserInfo, error) {
|
||||
// Call repository to create user
|
||||
userid, err := s.repo.CreateUser(user)
|
||||
if err != nil {
|
||||
return models.UserInfo{}, err
|
||||
return models.UserInfo{}, InviteOutcome{}, err
|
||||
}
|
||||
|
||||
// Get user info by id
|
||||
info, err := s.repo.GetUserById(userid)
|
||||
if err != nil {
|
||||
return models.UserInfo{}, err
|
||||
return models.UserInfo{}, InviteOutcome{}, err
|
||||
}
|
||||
|
||||
return info, nil
|
||||
// The invitation, after the write and outside it.
|
||||
//
|
||||
// This account is created with NO password — nothing on this path sets one —
|
||||
// and since the sign-in screen stopped offering to set a first password, the
|
||||
// emailed link is the only way in. Without this the person is added to the
|
||||
// directory, appears in every branch picker, and cannot sign in, with nothing
|
||||
// anywhere to say why.
|
||||
//
|
||||
// `info.Userid` rather than `userid`: identical, but this is the row that was
|
||||
// actually read back, so an invitation is never addressed to an id the
|
||||
// database did not confirm.
|
||||
return info, s.inviteNewAccount(info, user), nil
|
||||
}
|
||||
|
||||
// inviteNewAccount emails the person who was just hired.
|
||||
//
|
||||
// Never an error. A failure is the operator's task — resend, or fix the address —
|
||||
// and not a reason to unwind a hire that has already happened.
|
||||
func (s *userService) inviteNewAccount(info models.UserInfo, user models.User) InviteOutcome {
|
||||
if s.invites == nil {
|
||||
return InviteOutcome{Reason: "invitations are not configured on this server"}
|
||||
}
|
||||
if info.Userid <= 0 {
|
||||
return InviteOutcome{Reason: "the new account could not be read back to invite it"}
|
||||
}
|
||||
|
||||
// The authname IS the email on a back-office account — `PrepareNewAccount`
|
||||
// copies one to the other — so this is a fallback for a caller that filled in
|
||||
// only one of the two, never a second address.
|
||||
address := strings.TrimSpace(user.Email)
|
||||
if address == "" {
|
||||
address = strings.TrimSpace(user.Authname)
|
||||
}
|
||||
|
||||
// Empty business name: the invite service reads it from the tenantid. A staff
|
||||
// row carries the id and nothing else about the business.
|
||||
sent, reason := s.invites.Invite(info.Userid, user.Tenantid, address, "")
|
||||
return InviteOutcome{Sent: sent, Reason: reason}
|
||||
}
|
||||
|
||||
func (s *userService) TenantWebLogin(user models.User) (models.TenantUserInfo, map[string]interface{}) {
|
||||
@@ -297,16 +361,19 @@ func (s *userService) TenantWebLogin(user models.User) (models.TenantUserInfo, m
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Password checks
|
||||
// Step 3: Password checks.
|
||||
//
|
||||
// The userid is withheld here for the same reason as in `AppLogin` above:
|
||||
// this branch answers an unauthenticated caller asking about an email, and
|
||||
// the userid was half of an account takeover. See the long note there.
|
||||
if strings.TrimSpace(dbPassword) == "" {
|
||||
return models.TenantUserInfo{}, map[string]interface{}{
|
||||
"status": true,
|
||||
"code": 409,
|
||||
"message": "Please setup a password.",
|
||||
"message": "This account has not been set up yet. Use the invitation link that was emailed to you.",
|
||||
"tenantform": tenantFormExists,
|
||||
"details": map[string]interface{}{
|
||||
"userid": uid,
|
||||
"setup": true,
|
||||
"setup": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user