diff --git a/.env-example b/.env-example index 4d6f901ae0..bc0ead2894 100644 --- a/.env-example +++ b/.env-example @@ -93,6 +93,9 @@ RAILS_LOG_LEVEL=warn # SMS_GATEWAY_PASSWORD= ## Set to replace the organization name # SMS_GATEWAY_PLATFORM="hashimoto.local" +## In case you're using Sendethics service +SMS_GATEWAY_MB_API_KEY= +SMS_GATEWAY_MB_ACCOUNT_ID= #Timeout for the unsubscribe link of the newsletter # NEWSLETTERS_UNSUBSCRIBE_TIMEOUT= diff --git a/app/services/decidim/sms_gateway_service.rb b/app/services/decidim/sms_gateway_service.rb index 021ce7c291..e13b2778f6 100644 --- a/app/services/decidim/sms_gateway_service.rb +++ b/app/services/decidim/sms_gateway_service.rb @@ -8,28 +8,46 @@ class SmsGatewayService attr_reader :mobile_phone_number, :code def initialize(mobile_phone_number, code, sms_gateway_context = {}) - Rails.logger.debug { "#{mobile_phone_number} - #{code}" } - @mobile_phone_number = mobile_phone_number @code = code @organization_name = sms_gateway_context[:organization]&.name @url = fetch_configuration(:url) - @username = fetch_configuration(:username) - @password = fetch_configuration(:password) + @username = fetch_configuration(:username, required: false) + @password = fetch_configuration(:password, required: false) + @mb_account_id = fetch_configuration(:mb_account_id, required: false) + @mb_api_key = fetch_configuration(:mb_api_key, required: false) @message = sms_message @type = "sms" end def deliver_code - url = URI("#{@url}?u=#{@username}&p=#{@password}&t=#{@message}&n=#{@mobile_phone_number}&f=#{@type}") + url, request = build_request https = Net::HTTP.new(url.host, url.port) https.use_ssl = true - request = Net::HTTP::Get.new(url) https.request(request) - true end + def build_request + if @url.include?("services.message-business.com") + url = URI("#{@url}/sms/send") + request = Net::HTTP::Post.new(url) + + request["Content-Type"] = "application/json" + request["Authorization"] = "Basic #{Base64.strict_encode64("#{@mb_account_id}:#{@mb_api_key}")}" + + request.body = JSON.dump({ + "mobile" => @mobile_phone_number, + "message" => @message + }) + else + url = URI("#{@url}?u=#{@username}&p=#{@password}&t=#{@message}&n=#{@mobile_phone_number}&f=#{@type}") + request = Net::HTTP::Get.new(url) + end + + [url, request] + end + # Ensure '@code' is not a full i18n keys rather than a verification code. def sms_message return code if code.to_s.length > Decidim::HalfSignup.auth_code_length diff --git a/config/secrets.yml b/config/secrets.yml index 00ccba14d0..e9c2c8d3f8 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -52,6 +52,8 @@ default: &default username: <%= ENV["SMS_GATEWAY_USERNAME"] %> password: <%= ENV["SMS_GATEWAY_PASSWORD"] %> platform: <%= ENV["SMS_GATEWAY_PLATFORM"] %> + mb_api_key: <%= ENV["SMS_GATEWAY_MB_API_KEY"] %> + mb_account_id: <%= ENV["SMS_GATEWAY_MB_ACCOUNT_ID"] %> newsletters_unsubscribe_timeout: <%= ENV.fetch("NEWSLETTERS_UNSUBSCRIBE_TIMEOUT", 365).to_i %> modules: gallery: diff --git a/spec/services/decidim/sms_gateway_service_spec.rb b/spec/services/decidim/sms_gateway_service_spec.rb new file mode 100644 index 0000000000..cc8607fc0c --- /dev/null +++ b/spec/services/decidim/sms_gateway_service_spec.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Decidim::SmsGatewayService, type: :service do + let(:mobile_phone_number) { "+1234567890" } + let(:code) { "1234" } + let(:sms_gateway_context) { { organization: double("organization", name: "Test Organization") } } + let(:url) { "https://services.message-business.com" } + let(:username) { "test_user" } + let(:password) { "test_password" } + let(:mb_account_id) { "test_account" } + let(:mb_api_key) { "test_key" } + let(:platform) { "Test Platform" } + let(:service) { described_class.new(mobile_phone_number, code, sms_gateway_context) } + + before do + allow(Rails.application).to receive(:secrets).and_return( + decidim: { + sms_gateway: { + url: url, + username: username, + password: password, + mb_account_id: mb_account_id, + mb_api_key: mb_api_key, + platform: platform + } + } + ) + end + + describe "#initialize" do + it "initializes with the correct attributes" do + expect(service.mobile_phone_number).to eq(mobile_phone_number) + expect(service.code).to eq(code) + expect(service.instance_variable_get(:@organization_name)).to eq("Test Organization") + expect(service.instance_variable_get(:@url)).to eq(url) + expect(service.instance_variable_get(:@username)).to eq(username) + expect(service.instance_variable_get(:@password)).to eq(password) + expect(service.instance_variable_get(:@mb_account_id)).to eq(mb_account_id) + expect(service.instance_variable_get(:@mb_api_key)).to eq(mb_api_key) + expect(service.instance_variable_get(:@type)).to eq("sms") + end + end + + describe "#sms_message" do + context "when code length is greater than auth code length" do + before do + allow(Decidim::HalfSignup).to receive(:auth_code_length).and_return(3) + end + + it "returns the code as the message" do + expect(service.sms_message).to eq(code) + end + end + + context "when code length is less than or equal to auth code length" do + before do + allow(Decidim::HalfSignup).to receive(:auth_code_length).and_return(6) + end + + it "returns the localized message" do + localized_message = "Your verification code is 1234 for Test Platform" + allow(I18n).to receive(:t).and_return(localized_message) + expect(service.sms_message).to eq(localized_message) + end + end + end + + describe "#build_request" do + context "when URL is for Message Business service" do + let(:url) { "https://services.message-business.com" } + let(:mb_account_id) { "fake_account_id" } + let(:mb_api_key) { "fake_api_key" } + + before do + # Override with specific test values for headers verification + allow(Rails.application).to receive(:secrets).and_return( + decidim: { + sms_gateway: { + url: url, + username: username, + password: password, + mb_account_id: mb_account_id, + mb_api_key: mb_api_key, + platform: platform + } + } + ) + end + + it "builds a POST request with the correct headers and body" do + request_url, request = service.build_request + + expect(request_url.to_s).to eq("https://services.message-business.com/sms/send") + expect(request.body).to eq({ + "mobile" => mobile_phone_number, + "message" => service.sms_message + }.to_json) + + expect(request["Content-Type"]).to eq("application/json") + + # Verify the exact value of the Authorization header + encoded_credentials = Base64.strict_encode64("#{mb_account_id}:#{mb_api_key}") + expect(request["Authorization"]).to eq("Basic #{encoded_credentials}") + end + end + + context "when URL is for a generic SMS gateway" do + let(:url) { "https://generic-sms-gateway.com" } + + it "builds a GET request with the correct query parameters" do + request_url, request = service.build_request + + expect(request_url.to_s).to include("https://generic-sms-gateway.com?u=test_user&p=test_password") + sms_message_encoded = service.sms_message.gsub(" ", "%20") + expect(request_url.to_s).to include("&t=#{sms_message_encoded}&n=#{mobile_phone_number}&f=sms") + expect(request.method).to eq("GET") + end + end + + context "when the message contains unsupported characters" do + let(:code) { "Invalid 😃 Characters" } + + it "encodes unsupported characters correctly in the request" do + request_body = service.build_request[1].body + expect(request_body.to_s).to include(code) + end + end + end + + describe "#deliver_code" do + context "when using Message Business service" do + let(:url) { "https://services.message-business.com" } + let(:message_url) { "#{url}/sms/send" } + + before do + stub_request(:post, message_url) + .with( + body: { + "mobile" => mobile_phone_number, + "message" => service.sms_message + }.to_json, + headers: { + "Content-Type" => "application/json", + "Authorization" => "Basic #{Base64.strict_encode64("#{mb_account_id}:#{mb_api_key}")}" + } + ) + .to_return(status: 200, body: "", headers: {}) + end + + it "makes a POST request to send the SMS" do + expect(service.deliver_code).to be true + end + + context "when using valid credentials" do + before do + stub_request(:post, message_url) + .with( + body: { + "mobile" => mobile_phone_number, + "message" => service.sms_message + }.to_json, + headers: { + "Content-Type" => "application/json", + "Authorization" => "Basic #{Base64.strict_encode64("#{mb_account_id}:#{mb_api_key}")}" + } + ) + .to_return(status: 200, body: '{"success": true}', headers: {}) + end + + it "delivers the SMS successfully" do + expect(service.deliver_code).to be true + expect(WebMock).to have_requested(:post, message_url) + end + end + end + + context "when using a generic SMS gateway" do + let(:url) { "https://generic-sms-gateway.com" } + + before do + stub_request(:get, /generic-sms-gateway.com.*/) + .to_return(status: 200, body: "", headers: {}) + end + + it "makes a GET request to send the SMS" do + expect(service.deliver_code).to be true + end + end + + context "when using Message Business service with invalid credentials" do + let(:url) { "https://services.message-business.com" } + let(:message_url) { "#{url}/sms/send" } + + before do + stub_request(:post, message_url) + .with( + body: { + "mobile" => mobile_phone_number, + "message" => service.sms_message + }.to_json, + headers: { + "Content-Type" => "application/json", + "MB_ACCOUNT_ID" => "invalid_account", + "MB_API_KEY" => "invalid_key" + } + ) + .to_return(status: 401, body: "Unauthorized") + end + + it "fails to deliver SMS and returns unauthorized error" do + expect { service.deliver_code }.to raise_error(WebMock::NetConnectNotAllowedError) + end + end + end + + describe "#fetch_configuration" do + context "when a configuration value is missing" do + it "logs an error and returns nil" do + expect(Rails.logger).to receive(:error).with(/is missing a configuration value for :missing_key/) + expect(service.fetch_configuration(:missing_key, required: true)).to be_nil + end + end + end +end