444 lines
13 KiB
Go
444 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
"github.com/supabase/auth/internal/api/sms_provider"
|
|
"github.com/supabase/auth/internal/conf"
|
|
"github.com/supabase/auth/internal/models"
|
|
)
|
|
|
|
type PhoneTestSuite struct {
|
|
suite.Suite
|
|
API *API
|
|
Config *conf.GlobalConfiguration
|
|
}
|
|
|
|
type TestSmsProvider struct {
|
|
mock.Mock
|
|
|
|
SentMessages int
|
|
}
|
|
|
|
func (t *TestSmsProvider) SendMessage(phone, message, channel, otp string) (string, error) {
|
|
t.SentMessages += 1
|
|
return "", nil
|
|
}
|
|
func (t *TestSmsProvider) VerifyOTP(phone, otp string) error {
|
|
return nil
|
|
}
|
|
|
|
func TestPhone(t *testing.T) {
|
|
api, config, err := setupAPIForTest()
|
|
require.NoError(t, err)
|
|
|
|
ts := &PhoneTestSuite{
|
|
API: api,
|
|
Config: config,
|
|
}
|
|
defer api.db.Close()
|
|
|
|
suite.Run(t, ts)
|
|
}
|
|
|
|
func (ts *PhoneTestSuite) SetupTest() {
|
|
models.TruncateAll(ts.API.db)
|
|
|
|
// Create user
|
|
u, err := models.NewUser("123456789", "", "password", ts.Config.JWT.Aud, nil)
|
|
require.NoError(ts.T(), err, "Error creating test user model")
|
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user")
|
|
}
|
|
|
|
func (ts *PhoneTestSuite) TestValidateE164Format() {
|
|
isValid := validateE164Format("0123456789")
|
|
assert.Equal(ts.T(), false, isValid)
|
|
}
|
|
|
|
func (ts *PhoneTestSuite) TestFormatPhoneNumber() {
|
|
actual := formatPhoneNumber("+1 23456789 ")
|
|
assert.Equal(ts.T(), "123456789", actual)
|
|
}
|
|
|
|
func doTestSendPhoneConfirmation(ts *PhoneTestSuite, useTestOTP bool) {
|
|
u, err := models.FindUserByPhoneAndAudience(ts.API.db, "123456789", ts.Config.JWT.Aud)
|
|
require.NoError(ts.T(), err)
|
|
req, err := http.NewRequest("POST", "http://localhost:9998/otp", nil)
|
|
require.NoError(ts.T(), err)
|
|
cases := []struct {
|
|
desc string
|
|
otpType string
|
|
expected error
|
|
}{
|
|
{
|
|
desc: "send confirmation otp",
|
|
otpType: phoneConfirmationOtp,
|
|
expected: nil,
|
|
},
|
|
{
|
|
desc: "send phone_change otp",
|
|
otpType: phoneChangeVerification,
|
|
expected: nil,
|
|
},
|
|
{
|
|
desc: "send recovery otp",
|
|
otpType: phoneReauthenticationOtp,
|
|
expected: nil,
|
|
},
|
|
{
|
|
desc: "send invalid otp type ",
|
|
otpType: "invalid otp type",
|
|
expected: internalServerError("invalid otp type"),
|
|
},
|
|
}
|
|
|
|
if useTestOTP {
|
|
ts.API.config.Sms.TestOTP = map[string]string{
|
|
"123456789": "123456",
|
|
}
|
|
} else {
|
|
ts.API.config.Sms.TestOTP = nil
|
|
}
|
|
|
|
for _, c := range cases {
|
|
ts.Run(c.desc, func() {
|
|
provider := &TestSmsProvider{}
|
|
sms_provider.MockProvider = provider
|
|
|
|
_, err = ts.API.sendPhoneConfirmation(req, ts.API.db, u, "123456789", c.otpType, sms_provider.SMSProvider)
|
|
require.Equal(ts.T(), c.expected, err)
|
|
u, err = models.FindUserByPhoneAndAudience(ts.API.db, "123456789", ts.Config.JWT.Aud)
|
|
require.NoError(ts.T(), err)
|
|
|
|
if c.expected == nil {
|
|
if useTestOTP {
|
|
require.Equal(ts.T(), provider.SentMessages, 0)
|
|
} else {
|
|
require.Equal(ts.T(), provider.SentMessages, 1)
|
|
}
|
|
}
|
|
|
|
switch c.otpType {
|
|
case phoneConfirmationOtp:
|
|
require.NotEmpty(ts.T(), u.ConfirmationToken)
|
|
require.NotEmpty(ts.T(), u.ConfirmationSentAt)
|
|
case phoneChangeVerification:
|
|
require.NotEmpty(ts.T(), u.PhoneChangeToken)
|
|
require.NotEmpty(ts.T(), u.PhoneChangeSentAt)
|
|
case phoneReauthenticationOtp:
|
|
require.NotEmpty(ts.T(), u.ReauthenticationToken)
|
|
require.NotEmpty(ts.T(), u.ReauthenticationSentAt)
|
|
default:
|
|
}
|
|
})
|
|
}
|
|
// Reset at end of test
|
|
ts.API.config.Sms.TestOTP = nil
|
|
|
|
}
|
|
|
|
func (ts *PhoneTestSuite) TestSendPhoneConfirmation() {
|
|
doTestSendPhoneConfirmation(ts, false)
|
|
}
|
|
|
|
func (ts *PhoneTestSuite) TestSendPhoneConfirmationWithTestOTP() {
|
|
doTestSendPhoneConfirmation(ts, true)
|
|
}
|
|
|
|
func (ts *PhoneTestSuite) TestMissingSmsProviderConfig() {
|
|
u, err := models.FindUserByPhoneAndAudience(ts.API.db, "123456789", ts.Config.JWT.Aud)
|
|
require.NoError(ts.T(), err)
|
|
now := time.Now()
|
|
u.PhoneConfirmedAt = &now
|
|
require.NoError(ts.T(), ts.API.db.Update(u), "Error updating new test user")
|
|
|
|
s, err := models.NewSession(u.ID, nil)
|
|
require.NoError(ts.T(), err)
|
|
require.NoError(ts.T(), ts.API.db.Create(s))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/token?grant_type=password", nil)
|
|
token, _, err := ts.API.generateAccessToken(req, ts.API.db, u, &s.ID, models.PasswordGrant)
|
|
require.NoError(ts.T(), err)
|
|
|
|
cases := []struct {
|
|
desc string
|
|
endpoint string
|
|
method string
|
|
header string
|
|
body map[string]string
|
|
expected map[string]interface{}
|
|
}{
|
|
{
|
|
desc: "Signup",
|
|
endpoint: "/signup",
|
|
method: http.MethodPost,
|
|
header: "",
|
|
body: map[string]string{
|
|
"phone": "1234567890",
|
|
"password": "testpassword",
|
|
},
|
|
expected: map[string]interface{}{
|
|
"code": http.StatusInternalServerError,
|
|
"message": "Unable to get SMS provider",
|
|
},
|
|
},
|
|
{
|
|
desc: "Sms OTP",
|
|
endpoint: "/otp",
|
|
method: http.MethodPost,
|
|
header: "",
|
|
body: map[string]string{
|
|
"phone": "123456789",
|
|
},
|
|
expected: map[string]interface{}{
|
|
"code": http.StatusInternalServerError,
|
|
"message": "Unable to get SMS provider",
|
|
},
|
|
},
|
|
{
|
|
desc: "Phone change",
|
|
endpoint: "/user",
|
|
method: http.MethodPut,
|
|
header: token,
|
|
body: map[string]string{
|
|
"phone": "111111111",
|
|
},
|
|
expected: map[string]interface{}{
|
|
"code": http.StatusInternalServerError,
|
|
"message": "Unable to get SMS provider",
|
|
},
|
|
},
|
|
{
|
|
desc: "Reauthenticate",
|
|
endpoint: "/reauthenticate",
|
|
method: http.MethodGet,
|
|
header: "",
|
|
body: nil,
|
|
expected: map[string]interface{}{
|
|
"code": http.StatusInternalServerError,
|
|
"message": "Unable to get SMS provider",
|
|
},
|
|
},
|
|
}
|
|
|
|
smsProviders := []string{"twilio", "messagebird", "textlocal", "vonage"}
|
|
ts.Config.External.Phone.Enabled = true
|
|
ts.Config.Sms.Twilio.AccountSid = ""
|
|
ts.Config.Sms.Messagebird.AccessKey = ""
|
|
ts.Config.Sms.Textlocal.ApiKey = ""
|
|
ts.Config.Sms.Vonage.ApiKey = ""
|
|
|
|
for _, c := range cases {
|
|
for _, provider := range smsProviders {
|
|
ts.Config.Sms.Provider = provider
|
|
desc := fmt.Sprintf("[%v] %v", provider, c.desc)
|
|
ts.Run(desc, func() {
|
|
var buffer bytes.Buffer
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body))
|
|
|
|
req := httptest.NewRequest(c.method, "http://localhost"+c.endpoint, &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
|
|
|
w := httptest.NewRecorder()
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
require.Equal(ts.T(), c.expected["code"], w.Code)
|
|
|
|
body := w.Body.String()
|
|
require.True(ts.T(),
|
|
strings.Contains(body, "Unable to get SMS provider") ||
|
|
strings.Contains(body, "Error finding SMS provider") ||
|
|
strings.Contains(body, "Failed to get SMS provider"),
|
|
"unexpected body message %q", body,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
func (ts *PhoneTestSuite) TestSendSMSHook() {
|
|
u, err := models.FindUserByPhoneAndAudience(ts.API.db, "123456789", ts.Config.JWT.Aud)
|
|
require.NoError(ts.T(), err)
|
|
now := time.Now()
|
|
u.PhoneConfirmedAt = &now
|
|
require.NoError(ts.T(), ts.API.db.Update(u), "Error updating new test user")
|
|
|
|
s, err := models.NewSession(u.ID, nil)
|
|
require.NoError(ts.T(), err)
|
|
require.NoError(ts.T(), ts.API.db.Create(s))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/token?grant_type=password", nil)
|
|
token, _, err := ts.API.generateAccessToken(req, ts.API.db, u, &s.ID, models.PasswordGrant)
|
|
require.NoError(ts.T(), err)
|
|
|
|
// We setup a job table to enqueue SMS requests to send. Similar in spirit to the pg_boss postgres extension
|
|
createJobsTableSQL := `CREATE TABLE job_queue (
|
|
id serial PRIMARY KEY,
|
|
job_type text,
|
|
payload jsonb,
|
|
status text DEFAULT 'pending', -- Possible values: 'pending', 'processing', 'completed', 'failed'
|
|
created_at timestamp without time zone DEFAULT NOW()
|
|
);`
|
|
require.NoError(ts.T(), ts.API.db.RawQuery(createJobsTableSQL).Exec())
|
|
|
|
type sendSMSHookTestCase struct {
|
|
desc string
|
|
uri string
|
|
endpoint string
|
|
method string
|
|
header string
|
|
body map[string]string
|
|
hookFunctionSQL string
|
|
expectedCode int
|
|
expectToken bool
|
|
hookFunctionIdentifier string
|
|
}
|
|
cases := []sendSMSHookTestCase{
|
|
{
|
|
desc: "Phone signup using Hook",
|
|
endpoint: "/signup",
|
|
method: http.MethodPost,
|
|
uri: "pg-functions://postgres/auth/send_sms_signup",
|
|
hookFunctionSQL: `
|
|
create or replace function send_sms_signup(input jsonb)
|
|
returns json as $$
|
|
begin
|
|
insert into job_queue(job_type, payload)
|
|
values ('sms_signup', input);
|
|
return input;
|
|
end; $$ language plpgsql;`,
|
|
header: "",
|
|
body: map[string]string{
|
|
"phone": "1234567890",
|
|
"password": "testpassword",
|
|
},
|
|
expectedCode: http.StatusOK,
|
|
hookFunctionIdentifier: "send_sms_signup(input jsonb)",
|
|
},
|
|
{
|
|
desc: "SMS OTP sign in using hook",
|
|
endpoint: "/otp",
|
|
method: http.MethodPost,
|
|
uri: "pg-functions://postgres/auth/send_sms_otp",
|
|
hookFunctionSQL: `
|
|
create or replace function send_sms_otp(input jsonb)
|
|
returns json as $$
|
|
begin
|
|
insert into job_queue(job_type, payload)
|
|
values ('sms_signup', input);
|
|
return input;
|
|
end; $$ language plpgsql;`,
|
|
header: "",
|
|
body: map[string]string{
|
|
"phone": "123456789",
|
|
},
|
|
expectToken: false,
|
|
expectedCode: http.StatusOK,
|
|
hookFunctionIdentifier: "send_sms_otp(input jsonb)",
|
|
},
|
|
{
|
|
desc: "Phone Change",
|
|
endpoint: "/user",
|
|
method: http.MethodPut,
|
|
uri: "pg-functions://postgres/auth/send_sms_phone_change",
|
|
hookFunctionSQL: `
|
|
create or replace function send_sms_phone_change(input jsonb)
|
|
returns json as $$
|
|
begin
|
|
insert into job_queue(job_type, payload)
|
|
values ('phone_change', input);
|
|
return input;
|
|
end; $$ language plpgsql;`,
|
|
header: token,
|
|
body: map[string]string{
|
|
"phone": "111111111",
|
|
},
|
|
expectToken: true,
|
|
expectedCode: http.StatusOK,
|
|
hookFunctionIdentifier: "send_sms_phone_change(input jsonb)",
|
|
},
|
|
{
|
|
desc: "Reauthenticate",
|
|
endpoint: "/reauthenticate",
|
|
method: http.MethodGet,
|
|
uri: "pg-functions://postgres/auth/reauthenticate",
|
|
hookFunctionSQL: `
|
|
create or replace function reauthenticate(input jsonb)
|
|
returns json as $$
|
|
begin
|
|
return input;
|
|
end; $$ language plpgsql;`,
|
|
header: "",
|
|
body: nil,
|
|
expectToken: true,
|
|
expectedCode: http.StatusOK,
|
|
hookFunctionIdentifier: "reauthenticate(input jsonb)",
|
|
},
|
|
{
|
|
desc: "SMS OTP Hook (Error)",
|
|
endpoint: "/otp",
|
|
method: http.MethodPost,
|
|
uri: "pg-functions://postgres/auth/send_sms_otp_failure",
|
|
hookFunctionSQL: `
|
|
create or replace function send_sms_otp(input jsonb)
|
|
returns json as $$
|
|
begin
|
|
RAISE EXCEPTION 'Intentional Error for Testing';
|
|
end; $$ language plpgsql;`,
|
|
header: "",
|
|
body: map[string]string{
|
|
"phone": "123456789",
|
|
},
|
|
expectToken: false,
|
|
expectedCode: http.StatusInternalServerError,
|
|
hookFunctionIdentifier: "send_sms_otp_failure(input jsonb)",
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
ts.T().Run(c.desc, func(t *testing.T) {
|
|
|
|
ts.Config.External.Phone.Enabled = true
|
|
ts.Config.Hook.SendSMS.Enabled = true
|
|
ts.Config.Hook.SendSMS.URI = c.uri
|
|
// Disable FrequencyLimit to allow back to back sending
|
|
ts.Config.Sms.MaxFrequency = 0 * time.Second
|
|
require.NoError(ts.T(), ts.Config.Hook.SendSMS.PopulateExtensibilityPoint())
|
|
|
|
require.NoError(t, ts.API.db.RawQuery(c.hookFunctionSQL).Exec())
|
|
|
|
var buffer bytes.Buffer
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body))
|
|
req := httptest.NewRequest(c.method, "http://localhost"+c.endpoint, &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
|
|
|
w := httptest.NewRecorder()
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, c.expectedCode, w.Code, "Unexpected HTTP status code")
|
|
|
|
// Delete the function and reset env
|
|
cleanupHookSQL := fmt.Sprintf("drop function if exists %s", ts.Config.Hook.SendSMS.HookName)
|
|
require.NoError(t, ts.API.db.RawQuery(cleanupHookSQL).Exec())
|
|
ts.Config.Hook.SendSMS.Enabled = false
|
|
ts.Config.Sms.MaxFrequency = 1 * time.Second
|
|
})
|
|
}
|
|
|
|
// Cleanup
|
|
deleteJobsTableSQL := `drop table if exists job_queue`
|
|
require.NoError(ts.T(), ts.API.db.RawQuery(deleteJobsTableSQL).Exec())
|
|
|
|
}
|