This commit is contained in:
2026-09-16 11:42:06 +05:30
parent bf5a9026fe
commit 25bc33975c
7 changed files with 482 additions and 3 deletions

191
internal/sms/http_sender.go Normal file
View File

@@ -0,0 +1,191 @@
package sms
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"doormile/utils"
)
// A provider-agnostic HTTP gateway, and the wiring that installs it.
//
// This package called itself "the seam, not the integration", with a logging
// sink standing in until a gateway was plugged in. The sink was never replaced:
// sms.Register had no callers anywhere in the tree, so every customer
// verification code since this surface shipped has gone to the application log
// and nowhere else. Two consequences, both live in production:
//
// 1. No customer can complete sign-in without somebody reading the server log
// to them. That is the single blocker on the customer app, and it is why
// the app's offline dev mode became the only practical way in — which in
// turn is why bookings "made" in it never reached the admin console.
// 2. Every OTP ever issued is sitting in log storage as plaintext. A
// credential in a log file is a credential in the wrong place.
//
// Rather than hard-coding one vendor, this posts to whatever gateway the
// deployment names. The Indian providers this would plausibly use — MSG91,
// Gupshup, Textlocal, Fast2SMS — all accept an authenticated POST carrying a
// destination and a body, so one templated request covers them and swapping
// vendors is configuration rather than a release.
//
// Configuration, all read once at startup. An absent SMS_GATEWAY_URL leaves the
// log sink exactly where it is, so this change cannot break a deployment that
// has not been configured yet:
//
// SMS_GATEWAY_URL endpoint to POST to; absent means "stay on the log sink"
// SMS_GATEWAY_METHOD HTTP method, default POST
// SMS_GATEWAY_AUTH Authorization header value, for vendors that use one
// SMS_GATEWAY_HEADER one extra "Name: value" header, for vendors with their own key header
// SMS_GATEWAY_BODY body template; {{phone}}, {{message}} and {{sender}} are substituted
// SMS_GATEWAY_TYPE content type, default application/json
// SMS_SENDER_ID the registered sender id, substituted as {{sender}}
const gatewayTimeout = 10 * time.Second
type httpSender struct {
url string
method string
auth string
headerName string
headerValue string
bodyTmpl string
contentType string
senderID string
client *http.Client
}
func (httpSender) Name() string { return "http-gateway" }
func (s httpSender) Send(phone, message string) error {
body := s.bodyTmpl
body = strings.ReplaceAll(body, "{{phone}}", phone)
body = strings.ReplaceAll(body, "{{message}}", jsonEscape(message))
body = strings.ReplaceAll(body, "{{sender}}", s.senderID)
ctx, cancel := context.WithTimeout(context.Background(), gatewayTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, s.method, s.url, bytes.NewReader([]byte(body)))
if err != nil {
return fmt.Errorf("sms: build gateway request: %w", err)
}
req.Header.Set("Content-Type", s.contentType)
if s.auth != "" {
req.Header.Set("Authorization", s.auth)
}
if s.headerName != "" {
req.Header.Set(s.headerName, s.headerValue)
}
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("sms: gateway unreachable: %w", err)
}
defer resp.Body.Close()
// Read a bounded slice of the response for the log. The gateway's reason
// for refusing — "insufficient balance", "DLT template not approved" — is
// the entire diagnosis, and it only ever appears in the body.
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The number is masked and the message is NOT logged: the message
// contains the code, which is the thing this whole file exists to keep
// out of the log.
utils.Error("sms: gateway rejected the send",
"status", resp.StatusCode,
"phone", maskPhone(phone),
"response", strings.TrimSpace(string(snippet)))
return fmt.Errorf("sms: gateway returned %d", resp.StatusCode)
}
utils.Info("sms: code delivered", "phone", maskPhone(phone))
return nil
}
// jsonEscape makes a message safe to interpolate into a JSON body template.
// The OTP text carries no quotes today, but a template is a template and the
// next message to go through here will not be this one.
func jsonEscape(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
default:
b.WriteRune(r)
}
}
return b.String()
}
// Configure installs a real gateway when one is configured, and says plainly
// which transport the process ended up with.
//
// Called once from main() after config load. Deliberately loud in both
// directions: a deployment that believes it can send texts and cannot is the
// exact failure that has been live in this service since the customer surface
// shipped, so it must not be possible to start without the answer appearing in
// the boot log.
func Configure() {
url := strings.TrimSpace(os.Getenv("SMS_GATEWAY_URL"))
if url == "" {
utils.Warn("SMS: no gateway configured (SMS_GATEWAY_URL is unset). " +
"Verification codes are written to THIS LOG and no text is sent. " +
"Customer sign-in cannot complete unless somebody reads the code out " +
"of here, or CX_STAGING_OTP is set on a non-production deployment.")
return
}
method := strings.ToUpper(strings.TrimSpace(os.Getenv("SMS_GATEWAY_METHOD")))
if method == "" {
method = http.MethodPost
}
bodyTmpl := os.Getenv("SMS_GATEWAY_BODY")
if strings.TrimSpace(bodyTmpl) == "" {
bodyTmpl = `{"to":"{{phone}}","message":"{{message}}","sender":"{{sender}}"}`
}
contentType := strings.TrimSpace(os.Getenv("SMS_GATEWAY_TYPE"))
if contentType == "" {
contentType = "application/json"
}
var headerName, headerValue string
if raw := strings.TrimSpace(os.Getenv("SMS_GATEWAY_HEADER")); raw != "" {
if name, value, ok := strings.Cut(raw, ":"); ok {
headerName = strings.TrimSpace(name)
headerValue = strings.TrimSpace(value)
} else {
utils.Warn("SMS: SMS_GATEWAY_HEADER is not in 'Name: value' form and was ignored",
"value", raw)
}
}
Register(httpSender{
url: url,
method: method,
auth: strings.TrimSpace(os.Getenv("SMS_GATEWAY_AUTH")),
headerName: headerName,
headerValue: headerValue,
bodyTmpl: bodyTmpl,
contentType: contentType,
senderID: strings.TrimSpace(os.Getenv("SMS_SENDER_ID")),
client: &http.Client{Timeout: gatewayTimeout},
})
}

View File

@@ -0,0 +1,186 @@
package sms
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// The gateway that closes the sign-in blocker.
//
// sms.Register had no callers anywhere in the tree, so logSender was never
// replaced and every customer verification code went to the application log
// instead of to a phone. That is why nobody could sign in, why the app's
// offline dev mode became the only practical way in, and why bookings "made"
// in that mode never reached the admin console.
func restoreSender(t *testing.T) {
t.Helper()
previous := active
t.Cleanup(func() { active = previous })
}
func testSender(url, bodyTmpl string) httpSender {
if bodyTmpl == "" {
bodyTmpl = `{"to":"{{phone}}","message":"{{message}}","sender":"{{sender}}"}`
}
return httpSender{
url: url,
method: http.MethodPost,
bodyTmpl: bodyTmpl,
contentType: "application/json",
senderID: "DRMILE",
client: &http.Client{Timeout: 5 * time.Second},
}
}
// The destination and the code have to actually reach the gateway.
func TestGatewaySendsPhoneAndCode(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
got = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
restoreSender(t)
Register(testSender(srv.URL, ""))
if err := SendOTP("+919876543210", "4821"); err != nil {
t.Fatalf("SendOTP: %v", err)
}
for _, want := range []string{"+919876543210", "4821", "DRMILE"} {
if !strings.Contains(got, want) {
t.Errorf("gateway body %q is missing %q", got, want)
}
}
}
// A refused send — no balance, unapproved DLT template — must surface as an
// error. Swallowing it tells the customer a code is on its way when it is not,
// which is precisely the failure logSender has been producing all along.
func TestGatewayRefusalIsReported(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusPaymentRequired)
_, _ = w.Write([]byte(`{"error":"insufficient balance"}`))
}))
defer srv.Close()
restoreSender(t)
Register(testSender(srv.URL, ""))
if err := SendOTP("+919876543210", "4821"); err == nil {
t.Error("a rejected send reported success — the customer would wait for a " +
"text that is never coming")
}
}
// An unreachable gateway is an error, not a silent no-op.
func TestUnreachableGatewayIsReported(t *testing.T) {
restoreSender(t)
Register(testSender("http://127.0.0.1:1/unreachable", ""))
if err := SendOTP("+919876543210", "4821"); err == nil {
t.Error("an unreachable gateway reported success")
}
}
// A quote in the message must not break a JSON body template.
func TestMessageIsEscapedForJSON(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
got = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
restoreSender(t)
Register(testSender(srv.URL, ""))
if err := active.Send("+919876543210", `say "hello"`); err != nil {
t.Fatalf("send: %v", err)
}
if strings.Contains(got, `say "hello"`) {
t.Errorf("an unescaped quote reached the JSON body: %q", got)
}
if !strings.Contains(got, `say \"hello\"`) {
t.Errorf("the message was not escaped as expected: %q", got)
}
}
// Vendors differ; the template is what makes one sender cover all of them.
func TestBodyTemplateIsVendorAgnostic(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
got = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
restoreSender(t)
Register(testSender(srv.URL, "mobiles={{phone}}&message={{message}}&sender={{sender}}"))
if err := SendOTP("+919876543210", "4821"); err != nil {
t.Fatalf("send: %v", err)
}
if !strings.HasPrefix(got, "mobiles=+919876543210&message=") {
t.Errorf("form-encoded template not honoured: %q", got)
}
}
// Configure is what gives sms.Register its first caller. With no URL it must
// leave the log sink exactly where it is, so this change cannot break a
// deployment that has not been configured yet.
func TestConfigureLeavesTheLogSinkWhenUnset(t *testing.T) {
restoreSender(t)
active = logSender{}
setEnv(t, "SMS_GATEWAY_URL", "")
Configure()
if Configured() {
t.Error("Configure installed a gateway with no SMS_GATEWAY_URL set")
}
if Transport() != "log" {
t.Errorf("Transport() = %q, want \"log\"", Transport())
}
}
func TestConfigureInstallsTheGatewayWhenSet(t *testing.T) {
restoreSender(t)
active = logSender{}
setEnv(t, "SMS_GATEWAY_URL", "https://sms.example.invalid/send")
Configure()
if !Configured() {
t.Fatal("Configure did not install a gateway despite SMS_GATEWAY_URL being set")
}
if Transport() != "http-gateway" {
t.Errorf("Transport() = %q, want \"http-gateway\"", Transport())
}
}
// In production the log sink must refuse rather than write a live credential
// to the log and report success.
func TestLogSenderRefusesInProduction(t *testing.T) {
restoreSender(t)
active = logSender{}
setEnv(t, "ENV", "production")
if err := SendOTP("+919876543210", "4821"); err == nil {
t.Error("with no gateway in production, SendOTP reported success — the code " +
"went to the log and the customer was told it was sent")
}
setEnv(t, "ENV", "development")
if err := SendOTP("+919876543210", "4821"); err != nil {
t.Errorf("outside production the log sink must still work for QA: %v", err)
}
}

View File

@@ -39,6 +39,17 @@ type logSender struct{}
func (logSender) Name() string { return "log" }
func (logSender) Send(phone, message string) error {
// In production this is a failure, not a fallback. A code that only
// reaches the application log has not been delivered, and returning nil
// reports a send that did not happen: the customer waits for a text that
// is never coming, and the endpoint cheerfully answers sent:true. An
// error at least surfaces as a clear failure on the sign-in screen.
if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") {
utils.Error("SMS NOT CONFIGURED in production — refusing to write a live "+
"verification code to the log. Set SMS_GATEWAY_URL.", "phone", maskPhone(phone))
return fmt.Errorf("sms: no gateway configured")
}
utils.Warn("SMS NOT CONFIGURED — code written to the log instead of being sent",
"phone", maskPhone(phone), "message", message)
return nil