backend requirements onthe xustomer app
This commit is contained in:
105
internal/sms/sms.go
Normal file
105
internal/sms/sms.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Package sms delivers one-time codes to a phone number.
|
||||
//
|
||||
// There is no SMS provider wired into this backend yet — the miler app
|
||||
// authenticates on a PIN and the console on a password, so nothing has ever
|
||||
// needed to send a text. The customer app's only credential is a code sent to a
|
||||
// phone, which makes this the one piece of the auth flow that cannot be
|
||||
// finished from inside this repository.
|
||||
//
|
||||
// So this package is the seam, not the integration: a small interface, a
|
||||
// logging sink that lets the whole flow be exercised end to end without a
|
||||
// provider, and a fixed-code mode for staging. Plugging in a real gateway
|
||||
// (MSG91, Gupshup, Twilio) means adding one Sender and selecting it here —
|
||||
// nothing above this package changes.
|
||||
package sms
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"doormile/utils"
|
||||
)
|
||||
|
||||
// Sender delivers a message to an E.164 phone number.
|
||||
type Sender interface {
|
||||
Send(phone, message string) error
|
||||
// Name identifies the transport in logs and in the readiness probe, so
|
||||
// "OTP not arriving" can be answered without reading code.
|
||||
Name() string
|
||||
}
|
||||
|
||||
// logSender writes the code to the application log instead of sending it.
|
||||
//
|
||||
// This is what runs until a gateway is configured. It is deliberately loud and
|
||||
// deliberately marked: an OTP in a log file is a credential in a log file, and
|
||||
// nobody should be able to reach production with this active and not know.
|
||||
type logSender struct{}
|
||||
|
||||
func (logSender) Name() string { return "log" }
|
||||
|
||||
func (logSender) Send(phone, message string) error {
|
||||
utils.Warn("SMS NOT CONFIGURED — code written to the log instead of being sent",
|
||||
"phone", maskPhone(phone), "message", message)
|
||||
return nil
|
||||
}
|
||||
|
||||
var active Sender = logSender{}
|
||||
|
||||
// Register installs the real gateway. Call it from main() once a provider is
|
||||
// configured; until then the log sink stays in place.
|
||||
func Register(s Sender) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
active = s
|
||||
utils.Info("SMS sender registered", "transport", s.Name())
|
||||
}
|
||||
|
||||
// Transport reports which sender is active, for the readiness probe.
|
||||
func Transport() string { return active.Name() }
|
||||
|
||||
// Configured reports whether a real gateway is in place. False means codes are
|
||||
// only reaching the log.
|
||||
func Configured() bool { return active.Name() != "log" }
|
||||
|
||||
// SendOTP delivers a login code.
|
||||
func SendOTP(phone, code string) error {
|
||||
if strings.TrimSpace(phone) == "" {
|
||||
return fmt.Errorf("sms: empty phone number")
|
||||
}
|
||||
msg := fmt.Sprintf("%s is your Doormile verification code. It expires in 5 minutes. Do not share it with anyone.", code)
|
||||
return active.Send(phone, msg)
|
||||
}
|
||||
|
||||
// maskPhone keeps the country code and the last two digits so a log line can be
|
||||
// matched to a support call without recording the number itself.
|
||||
func maskPhone(phone string) string {
|
||||
if len(phone) < 5 {
|
||||
return "***"
|
||||
}
|
||||
return phone[:3] + strings.Repeat("*", len(phone)-5) + phone[len(phone)-2:]
|
||||
}
|
||||
|
||||
// StagingCode returns the fixed verification code for non-production
|
||||
// environments, or "" when none is set.
|
||||
//
|
||||
// Automated tests and design QA cannot receive a real text, and the previous
|
||||
// end-to-end attempt on this system stalled for exactly that reason: customer
|
||||
// login needed an OTP on a real handset and could not be scripted. CX_STAGING_OTP
|
||||
// closes that.
|
||||
//
|
||||
// It is refused outright when ENV is production, because a fixed code is a
|
||||
// permanent skeleton key for every account on the platform.
|
||||
func StagingCode() string {
|
||||
code := strings.TrimSpace(os.Getenv("CX_STAGING_OTP"))
|
||||
if code == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") {
|
||||
utils.Error("CX_STAGING_OTP is set in a production environment and has been ignored — " +
|
||||
"a fixed verification code would accept a login for every account on the platform")
|
||||
return ""
|
||||
}
|
||||
return code
|
||||
}
|
||||
140
internal/sms/sms_test.go
Normal file
140
internal/sms/sms_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// StagingCode is a permanent skeleton key for every account on the platform if
|
||||
// it ever reaches production. The guard against that is the only thing standing
|
||||
// between a convenience for QA and a total auth bypass, so it is tested rather
|
||||
// than trusted.
|
||||
|
||||
func setEnv(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
previous, had := os.LookupEnv(key)
|
||||
if value == "" {
|
||||
_ = os.Unsetenv(key)
|
||||
} else {
|
||||
_ = os.Setenv(key, value)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if had {
|
||||
_ = os.Setenv(key, previous)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A fixed OTP is refused in production however the environment is spelt.
|
||||
func TestStagingCodeIsRefusedInProduction(t *testing.T) {
|
||||
for _, env := range []string{"production", "PRODUCTION", "Production", " production "} {
|
||||
setEnv(t, "CX_STAGING_OTP", "1234")
|
||||
setEnv(t, "ENV", env)
|
||||
|
||||
if got := StagingCode(); got != "" {
|
||||
t.Errorf("ENV=%q returned the fixed code %q — that is a skeleton key "+
|
||||
"for every account on the platform", env, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And is available everywhere else, which is what unblocks automated sign-in.
|
||||
func TestStagingCodeIsAvailableOutsideProduction(t *testing.T) {
|
||||
for _, env := range []string{"development", "staging", ""} {
|
||||
setEnv(t, "CX_STAGING_OTP", "1234")
|
||||
setEnv(t, "ENV", env)
|
||||
|
||||
if got := StagingCode(); got != "1234" {
|
||||
t.Errorf("ENV=%q returned %q, want the configured staging code", env, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unset means unset — no accidental default.
|
||||
func TestStagingCodeIsEmptyWhenNotConfigured(t *testing.T) {
|
||||
setEnv(t, "ENV", "development")
|
||||
setEnv(t, "CX_STAGING_OTP", "")
|
||||
|
||||
if got := StagingCode(); got != "" {
|
||||
t.Errorf("StagingCode() = %q with nothing configured, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Until a gateway is registered, Configured() must report false. Shipping while
|
||||
// this quietly said true would mean nobody noticed OTP codes were only reaching
|
||||
// the application log.
|
||||
func TestTransportReportsThatNoGatewayIsWired(t *testing.T) {
|
||||
if Configured() {
|
||||
t.Error("Configured() = true with no gateway registered — " +
|
||||
"the log sink must never claim to be a real transport")
|
||||
}
|
||||
if Transport() != "log" {
|
||||
t.Errorf("Transport() = %q, want \"log\"", Transport())
|
||||
}
|
||||
}
|
||||
|
||||
// Registering a gateway flips both, and Register(nil) is ignored rather than
|
||||
// silently disabling delivery.
|
||||
func TestRegisterInstallsAGatewayAndIgnoresNil(t *testing.T) {
|
||||
original := active
|
||||
t.Cleanup(func() { active = original })
|
||||
|
||||
Register(nil)
|
||||
if Transport() != "log" {
|
||||
t.Errorf("Register(nil) changed the transport to %q", Transport())
|
||||
}
|
||||
|
||||
fake := &recordingSender{}
|
||||
Register(fake)
|
||||
if !Configured() || Transport() != "test" {
|
||||
t.Fatalf("after Register: configured=%v transport=%q", Configured(), Transport())
|
||||
}
|
||||
|
||||
if err := SendOTP("+919876543210", "4821"); err != nil {
|
||||
t.Fatalf("SendOTP: %v", err)
|
||||
}
|
||||
if fake.phone != "+919876543210" {
|
||||
t.Errorf("phone = %q, want the number passed in", fake.phone)
|
||||
}
|
||||
if !strings.Contains(fake.message, "4821") {
|
||||
t.Errorf("message %q does not carry the code", fake.message)
|
||||
}
|
||||
if !strings.Contains(fake.message, "Do not share") {
|
||||
t.Errorf("message %q is missing the do-not-share warning", fake.message)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty number is refused rather than handed to a gateway that will bill for
|
||||
// it and fail.
|
||||
func TestSendOTPRefusesAnEmptyNumber(t *testing.T) {
|
||||
if err := SendOTP(" ", "4821"); err == nil {
|
||||
t.Error("SendOTP accepted an empty phone number")
|
||||
}
|
||||
}
|
||||
|
||||
// The log sink masks the number. An OTP in a log file is already bad enough
|
||||
// without the number it belongs to sitting beside it.
|
||||
func TestMaskPhoneHidesTheSubscriberDigits(t *testing.T) {
|
||||
got := maskPhone("+919876543210")
|
||||
if strings.Contains(got, "9876543") {
|
||||
t.Errorf("maskPhone = %q, still exposes the subscriber digits", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "10") {
|
||||
t.Errorf("maskPhone = %q, should keep the last two digits so a support "+
|
||||
"call can be matched", got)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingSender struct {
|
||||
phone string
|
||||
message string
|
||||
}
|
||||
|
||||
func (r *recordingSender) Name() string { return "test" }
|
||||
func (r *recordingSender) Send(phone, message string) error {
|
||||
r.phone, r.message = phone, message
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user