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:
parent
ccd22183db
commit
4edf851665
60
main.go
60
main.go
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue