Break out SMS/Message provider, add tests

This commit represents a first step in modularizing the Andrews & Arnold
SGX in particular, and setting up a pattern for future Go-based SGX
implementations more broadly, by breaking out the notion of a message
"provider", that is, a remote API integrated against in sending and
receiving messages.

Modularizing at the level of the provider here will allow unit tests to
be written against a mock provider in the future, but this commit also
integrates tests against the concrete A&A provider, using a mock HTTP
client.

Changes here have been kept intentionally minimal to reduce the diff;
future efforts will see us pull functionalities into their own packages
to better maintain separation of concerns.
This commit is contained in:
Alex Palaistras 2023-12-17 14:25:42 +00:00
parent ccd22183db
commit 4edf851665
3 changed files with 321 additions and 39 deletions

60
main.go
View File

@ -6,11 +6,9 @@ import (
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strings"
@ -38,40 +36,17 @@ func (u *User) Token() string {
return hex.EncodeToString(sha.Sum(nil))
}
type Message struct {
ID string
Dest string
Body string
}
var urlPrefix string
var componentDomain string
var rclient *redis.Client
var xmppSession *xmpp.Session
func SendSMS(user User, tel string, message string, stanzaId string) error {
response, err := http.PostForm(
"https://sms.aa.net.uk/sms.cgi",
url.Values{
"username": {user.tel},
"password": {user.password},
"da": {tel},
"ud": {message},
"limit": {"3"},
"costcentre": {"sgx-aa"},
"srr": {fmt.Sprintf("%s/%s/sms/srr?code=%%code&da=%%da&oa=%%oa&stanzaId=%s", urlPrefix, user.Token(), url.QueryEscape(stanzaId))},
},
)
if err != nil {
return err
}
defer response.Body.Close()
bodyBytes, err := io.ReadAll(io.LimitReader(response.Body, 4096))
if err != nil {
return err
}
body := string(bodyBytes)
if response.StatusCode >= 400 || strings.HasPrefix(body, "ERR") {
return fmt.Errorf("%s", body)
}
return nil
}
var provider Provider
func healthcheckHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "OK")
@ -260,14 +235,16 @@ func handleMessage(d *xml.Decoder, start *xml.StartElement) error {
return nil
}
err = SendSMS(
err = provider.SendMessage(
User{creds[0], creds[1]},
msg.To.Localpart(),
msg.Body,
msg.ID,
Message{
ID: msg.ID,
Body: msg.Body,
Dest: msg.To.Localpart(),
},
)
if err != nil {
log.Printf("SendSMS error: %s\n", err)
log.Printf("SendMessage error: %s\n", err)
xmppSession.Send(context.Background(), stanza.Message{
Type: stanza.ErrorMessage,
ID: msg.ID,
@ -424,7 +401,7 @@ func main() {
addr := os.Args[3]
conn, err := net.Dial("tcp", addr)
if err != nil {
log.Fatal("XMPP Dial ", err)
log.Fatalf("Failed XMPP dial: %s", err)
}
xmppSession, err = xmpp.NewSession(
context.Background(),
@ -435,7 +412,12 @@ func main() {
component.Negotiator(jid.MustParse(componentDomain), []byte(os.Args[2]), false),
)
if err != nil {
log.Fatal("XMPP Session ", err)
log.Fatalf("Failed setting up XMPP Session: %s", err)
}
provider, err = NewAndrewsArnoldProvider(WithReportHandler(urlPrefix))
if err != nil {
log.Fatalf("Failed initializing provider: %s", err)
}
go func() {

121
provider.go Normal file
View File

@ -0,0 +1,121 @@
package main
import (
// Standard library
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
// The default API endpoint used for sending messages.
upstreamEndpoint = "https://sms.aa.net.uk/sms.cgi"
// The maximum number of message parts we'll attempt to send at any one time.
defaultMessageLimit = 3
// The maximum amount of time any HTTP request might take to complete.
defaultHTTPTimeout = 15 * time.Second
)
// A Provider represents any method for sending and receiving messages from a remote source.
type Provider interface {
SendMessage(User, Message) error
}
// AndrewsArnoldProvider is a message [Provider] that implements support for the Andrews & Arnold
// SMS API.
type AndrewsArnoldProvider struct {
messageLimit int // The maximum number of message parts we'll attempt to send.
reportURI string // The URI used for requesting a delivery report.
client *http.Client // The underlying HTTP client to use in making requests to A&A.
}
// NewAndrewsArnoldProvider returns a [Provider] that can be used in sending and receiving messages
// over the Andrews & Arnold SMS API.
func NewAndrewsArnoldProvider(options ...ProviderOption) (*AndrewsArnoldProvider, error) {
var p = &AndrewsArnoldProvider{
messageLimit: defaultMessageLimit,
client: &http.Client{
Timeout: defaultHTTPTimeout,
},
}
for _, fn := range options {
if err := fn(p); err != nil {
return nil, err
}
}
return p, nil
}
type ProviderOption func(*AndrewsArnoldProvider) error
func WithReportHandler(hostname string) ProviderOption {
return func(p *AndrewsArnoldProvider) error {
if hostname == "" {
return fmt.Errorf("cannot use report handler with empty hostname")
}
p.reportURI = hostname + "/%s/sms/srr?code=%%code&da=%%da&oa=%%oa&stanzaId=%s"
return nil
}
}
// SendMessage attempts to send the given [Message], authenticating as the given source [User].
// Messages may be of a size up to the message limit defined at the [AndrewsArnoldProvider] level,
// and may fail to send otherwise. If the [Message.ID] field is set, a delivery report will be
// requested for the given message.
func (p *AndrewsArnoldProvider) SendMessage(user User, message Message) error {
if user.tel == "" || user.password == "" {
return fmt.Errorf("cannot send message for incomplete user information")
} else if message.Dest == "" {
return fmt.Errorf("cannot send message for empty destination number")
} else if message.Body == "" {
return nil
}
data := url.Values{
"username": {user.tel},
"password": {user.password},
"da": {message.Dest},
"ud": {message.Body},
"limit": {strconv.Itoa(p.messageLimit)},
"costcentre": {"sgx-aa"},
}
// Send delivery report if a (presumably unique) message ID was given.
// TODO: Maybe pull the report URI from an attached "delibery report handler".
if message.ID != "" && p.reportURI != "" {
data.Add("srr", fmt.Sprintf(p.reportURI, user.Token(), url.QueryEscape(message.ID)))
}
req, err := http.NewRequest("POST", upstreamEndpoint, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("failed preparing HTTP request for message: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("failed sending HTTP request for message: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return err
}
if msg, ok := strings.CutPrefix(string(body), "ERR:"); ok {
return fmt.Errorf("failed sending HTTP request for message: %s", msg)
} else if resp.StatusCode >= 400 {
return fmt.Errorf("failed sending HTTP request for message: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
}
return nil
}

179
provider_test.go Normal file
View File

@ -0,0 +1,179 @@
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
)
const (
testHostname = "https://example.com"
)
var newAndrewsArnoldProviderTest = []struct {
descr string
options []ProviderOption
err error
}{
{
descr: "empty hostname in report handler",
options: []ProviderOption{WithReportHandler("")},
err: fmt.Errorf("cannot use report handler with empty hostname"),
},
}
func TestNewAndrewsArnoldProvider(t *testing.T) {
for _, tt := range newAndrewsArnoldProviderTest {
t.Run(tt.descr, func(t *testing.T) {
_, err := NewAndrewsArnoldProvider(tt.options...)
if err != nil && tt.err == nil {
t.Errorf("NewAndrewsArnoldProvider(): want error 'nil', have '%s'", err)
} else if err == nil && tt.err != nil {
t.Errorf("NewAndrewsArnoldProvider(): want error '%s', have 'nil'", tt.err)
} else if err != nil && tt.err != nil && err.Error() != tt.err.Error() {
t.Errorf("NewAndrewsArnoldProvider(): want error '%s', have '%s'", tt.err, err)
}
})
}
}
var sendMessageTest = []struct {
descr string
user User
message Message
transport *mockTransport
values url.Values
err error
}{
{
descr: "empty username",
user: User{},
err: fmt.Errorf("cannot send message for incomplete user information"),
},
{
descr: "empty password",
user: User{tel: "+447700900000"},
err: fmt.Errorf("cannot send message for incomplete user information"),
},
{
descr: "empty destination number",
user: User{tel: "+447700900000", password: "some secret!"},
err: fmt.Errorf("cannot send message for empty destination number"),
},
{
descr: "empty body",
user: User{tel: "+447700900000", password: "some secret!"},
message: Message{Dest: "+447700900001"},
},
{
descr: "error in HTTP request",
user: User{tel: "+447700900000", password: "some secret!"},
message: Message{Dest: "+447700900001", Body: "hello world"},
transport: &mockTransport{err: fmt.Errorf("some darn error!")},
err: fmt.Errorf(`failed sending HTTP request for message: Post "%s": some darn error!`, upstreamEndpoint),
},
{
descr: "upstream error message",
user: User{tel: "+447700900000", password: "some secret!"},
message: Message{Dest: "+447700900001", Body: "hello world"},
transport: &mockTransport{body: "ERR:what is the what?"},
err: fmt.Errorf("failed sending HTTP request for message: what is the what?"),
},
{
descr: "upstream error code",
user: User{tel: "+447700900000", password: "some secret!"},
message: Message{Dest: "+447700900001", Body: "hello world"},
transport: &mockTransport{status: 403},
err: fmt.Errorf("failed sending HTTP request for message: 403 Forbidden"),
},
{
descr: "valid form body",
user: User{tel: "+447700900000", password: "some secret!"},
message: Message{Dest: "+447700900001", Body: "hello world"},
values: url.Values{
"username": {"+447700900000"},
"password": {"some secret!"},
"da": {"+447700900001"},
"ud": {"hello world"},
"limit": {"3"},
"costcentre": {"sgx-aa"},
},
},
{
descr: "valid form body with message ID",
user: User{tel: "+447700900000", password: "some secret!"},
message: Message{Dest: "+447700900001", Body: "hello world", ID: "foo bar"},
values: url.Values{
"username": {"+447700900000"},
"password": {"some secret!"},
"da": {"+447700900001"},
"ud": {"hello world"},
"limit": {"3"},
"costcentre": {"sgx-aa"},
"srr": {"https://example.com/c77f0ea3dcd450b6df09deb5dc8d7a92a1415cc6b4b86b0e62db668361fc1baf/sms/srr?code=%code&da=%da&oa=%oa&stanzaId=foo+bar"},
},
},
}
func TestSendMessage(t *testing.T) {
for _, tt := range sendMessageTest {
t.Run(tt.descr, func(t *testing.T) {
p, err := NewAndrewsArnoldProvider(WithReportHandler(testHostname))
if err != nil {
t.Fatalf("NewAndrewsArnoldProvider: %s", err)
}
tr := &mockTransport{}
if tt.transport != nil {
tr = tt.transport
}
p.client = &http.Client{Transport: tr}
err = p.SendMessage(tt.user, tt.message)
if err != nil && tt.err == nil {
t.Errorf("SendMessage(): want error 'nil', have '%s'", err)
} else if err == nil && tt.err != nil {
t.Errorf("SendMessage(): want error '%s', have 'nil'", tt.err)
} else if err != nil && tt.err != nil && err.Error() != tt.err.Error() {
t.Errorf("SendMessage(): want error '%s', have '%s'", tt.err, err)
} else if err != nil && tt.err != nil {
return
} else if expect := tt.values.Encode(); expect != tr.body {
t.Errorf("SendMessage(): want request body '%s', have '%s'", expect, tr.body)
}
})
}
}
type mockTransport struct {
status int
body string
err error
}
func (tr *mockTransport) RoundTrip(r *http.Request) (*http.Response, error) {
if tr.err != nil {
return nil, tr.err
}
if tr.body == "" {
if err := r.ParseForm(); err != nil {
return nil, err
}
tr.body = r.Form.Encode()
}
if tr.status == 0 {
tr.status = http.StatusOK
}
resp := &http.Response{
StatusCode: tr.status,
Body: io.NopCloser(strings.NewReader(tr.body)),
}
return resp, nil
}