auto mail generation
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user