@@ -9,8 +9,11 @@ import (
99
1010 "github.com/gofrs/uuid"
1111 "github.com/stretchr/testify/require"
12+ "gopkg.in/h2non/gock.v1"
1213
1314 "github.com/supabase/auth/internal/api/apierrors"
15+ "github.com/supabase/auth/internal/api/sms_provider"
16+ "github.com/supabase/auth/internal/conf"
1417 "github.com/supabase/auth/internal/crypto"
1518 mail "github.com/supabase/auth/internal/mailer"
1619 "github.com/supabase/auth/internal/models"
@@ -24,12 +27,13 @@ import (
2427// in the fixtures.
2528
2629const (
27- parityOTP = "123456"
28- parityEmail = "test@example.com"
29- parityPhone = "12345678"
30- parityNewEmail = "new@example.com"
31- parityNewPhone = "1234567890"
32- parityForbidden = "Token has expired or is invalid"
30+ parityOTP = "123456"
31+ parityEmail = "test@example.com"
32+ parityPhone = "12345678"
33+ parityNewEmail = "new@example.com"
34+ parityNewPhone = "1234567890"
35+ parityForbidden = "Token has expired or is invalid"
36+ twilioServiceSid = "VA-parity-test"
3337)
3438
3539// otpParityOutcome is everything a client or an operator can observe after a
@@ -276,6 +280,53 @@ func (ts *VerifyTestSuite) TestVerifyOTPParityPhoneFlows() {
276280 },
277281 expected : phoneChanged ,
278282 },
283+ {
284+ // A test OTP is accepted without any stored challenge. This is the
285+ // path app store reviewers and CI rely on.
286+ desc : "sms with a test OTP succeeds with no stored challenge" ,
287+ configure : func () func () {
288+ return ts .configureTestOTP (parityPhone , parityOTP )
289+ },
290+ seed : func (u * models.User ) map [string ]interface {} {
291+ return phoneOTPBody (smsVerification , parityPhone )
292+ },
293+ expected : phoneSignedUp ,
294+ },
295+ {
296+ desc : "sms with a wrong code falls through the test OTP check and is rejected" ,
297+ configure : func () func () {
298+ return ts .configureTestOTP (parityPhone , "000000" )
299+ },
300+ seed : func (u * models.User ) map [string ]interface {} {
301+ return phoneOTPBody (smsVerification , parityPhone )
302+ },
303+ expected : forbidden ,
304+ },
305+ {
306+ // Twilio Verify generates and delivers its own code, so the locally
307+ // stored hash never matches what the user types. Twilio's answer
308+ // is the only thing that counts.
309+ desc : "sms with Twilio Verify accepts a code Twilio approves" ,
310+ configure : func () func () {
311+ return ts .configureTwilioVerify (map [string ]interface {}{"status" : "approved" , "valid" : true })
312+ },
313+ seed : func (u * models.User ) map [string ]interface {} {
314+ ts .seedChallenge (u , models .ConfirmationToken , parityPhone , crypto .GenerateTokenHash (parityPhone , "999999" ), now , time .Hour )
315+ return phoneOTPBody (smsVerification , parityPhone )
316+ },
317+ expected : phoneSignedUp ,
318+ },
319+ {
320+ desc : "sms with Twilio Verify rejects a code Twilio does not approve" ,
321+ configure : func () func () {
322+ return ts .configureTwilioVerify (map [string ]interface {}{"status" : "pending" , "valid" : false })
323+ },
324+ seed : func (u * models.User ) map [string ]interface {} {
325+ ts .seedChallenge (u , models .ConfirmationToken , parityPhone , phoneHash , now , time .Hour )
326+ return phoneOTPBody (smsVerification , parityPhone )
327+ },
328+ expected : forbidden ,
329+ },
279330 }
280331
281332 ts .runOTPParityCases (cases )
@@ -330,7 +381,7 @@ func (ts *VerifyTestSuite) runOTPParityCases(cases []otpParityCase) {
330381
331382// seedChallenge stores hash in the users column and the one_time_tokens row
332383// for tokenType, mirroring what the send paths write. relatesTo is the address
333- // or number the code was sent to.
384+ // or number the code was sent to; the Twilio Verify path finds the row by it .
334385// Any other pending change on u is persisted at the same time.
335386func (ts * VerifyTestSuite ) seedChallenge (u * models.User , tokenType models.OneTimeTokenType , relatesTo , hash string , sentAt time.Time , validity time.Duration ) {
336387 switch tokenType {
@@ -398,6 +449,42 @@ func (ts *VerifyTestSuite) observeOutcome(w *httptest.ResponseRecorder, userID u
398449 return outcome
399450}
400451
452+ func (ts * VerifyTestSuite ) configureTestOTP (phone , otp string ) func () {
453+ previous := ts .Config .Sms .TestOTP
454+ ts .Config .Sms .TestOTP = map [string ]string {phone : otp }
455+ return func () { ts .Config .Sms .TestOTP = previous }
456+ }
457+
458+ // configureTwilioVerify switches the SMS provider to Twilio Verify and arms a
459+ // single mocked VerificationCheck response.
460+ func (ts * VerifyTestSuite ) configureTwilioVerify (response map [string ]interface {}) func () {
461+ previousProvider := ts .Config .Sms .Provider
462+ previousTwilio := ts .Config .Sms .TwilioVerify
463+ previousMock := sms_provider .MockProvider
464+
465+ ts .Config .Sms .Provider = "twilio_verify"
466+ ts .Config .Sms .TwilioVerify = conf.TwilioVerifyProviderConfiguration {
467+ AccountSid : "AC-parity-test" ,
468+ AuthToken : "parity-test-token" ,
469+ MessageServiceSid : twilioServiceSid ,
470+ }
471+ // The mock provider would short-circuit GetSmsProvider and never reach
472+ // the Twilio Verify type assertion.
473+ sms_provider .MockProvider = nil
474+
475+ gock .New ("https://verify.twilio.com/v2/Services/" + twilioServiceSid + "/VerificationCheck" ).
476+ Post ("" ).
477+ Reply (http .StatusOK ).
478+ JSON (response )
479+
480+ return func () {
481+ gock .OffAll ()
482+ sms_provider .MockProvider = previousMock
483+ ts .Config .Sms .TwilioVerify = previousTwilio
484+ ts .Config .Sms .Provider = previousProvider
485+ }
486+ }
487+
401488func emailOTPBody (verifyType , email string ) map [string ]interface {} {
402489 return map [string ]interface {}{
403490 "type" : verifyType ,
0 commit comments