Files
backend_fiesta/services/inviteService_test.go
2026-09-29 16:53:21 +05:30

242 lines
8.1 KiB
Go

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