242 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|