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) } }