# frozen_string_literal: true require "rails_helper" RSpec.describe "Legacy Messages API", type: :request do describe "/api/v1/messages/message" do context "when no authentication is provided" do it "returns an error" do post "/api/v1/messages/message" expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "error" expect(parsed_body["data"]["code"]).to eq "AccessDenied" end end context "when the credential does not match anything" do it "returns an error" do post "/api/v1/messages/message", headers: { "x-server-api-key" => "invalid" } expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "error" expect(parsed_body["data"]["code"]).to eq "InvalidServerAPIKey" end end context "when the credential belongs to a suspended server" do it "returns an error" do server = create(:server, :suspended) credential = create(:credential, server: server) post "/api/v1/messages/message", headers: { "x-server-api-key" => credential.key } expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "error" expect(parsed_body["data"]["code"]).to eq "ServerSuspended" end end context "when the credential is valid" do let(:server) { create(:server) } let(:credential) { create(:credential, server: server) } context "when no message ID is provided" do it "returns an error" do post "/api/v1/messages/message", headers: { "x-server-api-key" => credential.key } expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "parameter-error" expect(parsed_body["data"]["message"]).to match(/`id` parameter is required but is missing/) end end context "when the message ID does not exist" do it "returns an error" do post "/api/v1/messages/message", headers: { "x-server-api-key" => credential.key, "content-type" => "application/json" }, params: { id: 123 }.to_json expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "error" expect(parsed_body["data"]["code"]).to eq "MessageNotFound" end end # Regression tests for GHSA-x2hq-rfpg-3xr5. The request body is parsed as # JSON, so a JSON object/array supplied for `id` would otherwise arrive as # a Ruby Hash/Array and be passed straight through to the message database # as a raw set of SQL conditions (blind SQL injection). These must be # rejected before reaching the database. context "when the message ID is a JSON object (SQL injection attempt)" do it "rejects it with a parameter error and never reaches the database" do expect_any_instance_of(Server).not_to receive(:message) post "/api/v1/messages/message", headers: { "x-server-api-key" => credential.key, "content-type" => "application/json" }, params: { id: { "id`=0 OR SLEEP(5)#" => "x" } }.to_json expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "parameter-error" expect(parsed_body["data"]["message"]).to match(/must be a string or integer/) end end context "when the message ID is a JSON array" do it "rejects it with a parameter error" do post "/api/v1/messages/message", headers: { "x-server-api-key" => credential.key, "content-type" => "application/json" }, params: { id: [1, 2, 3] }.to_json expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "parameter-error" expect(parsed_body["data"]["message"]).to match(/must be a string or integer/) end end context "when the message ID is provided as a numeric string" do let(:message) { MessageFactory.outgoing(server) } it "is coerced to an integer and looks the message up" do post "/api/v1/messages/message", headers: { "x-server-api-key" => credential.key, "content-type" => "application/json" }, params: { id: message.id.to_s }.to_json expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "success" expect(parsed_body["data"]).to match({ "id" => message.id, "token" => message.token }) end end context "when the message ID exists" do let(:server) { create(:server) } let(:credential) { create(:credential, server: server) } let(:message) { MessageFactory.outgoing(server) } let(:expansions) { [] } before do post "/api/v1/messages/message", headers: { "x-server-api-key" => credential.key, "content-type" => "application/json" }, params: { id: message.id, _expansions: expansions }.to_json end context "when no expansions are requested" do it "returns details about the message" do expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "success" expect(parsed_body["data"]).to match({ "id" => message.id, "token" => message.token }) end end context "when all expansions are requested" do let(:expansions) { true } it "returns details about the message" do expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "success" expect(parsed_body["data"]).to match({ "id" => message.id, "token" => message.token, "status" => { "held" => false, "hold_expiry" => nil, "last_delivery_attempt" => nil, "status" => "Pending" }, "details" => { "bounce" => false, "bounce_for_id" => 0, "direction" => "outgoing", "mail_from" => "test@example.com", "message_id" => message.message_id, "rcpt_to" => "john@example.com", "received_with_ssl" => nil, "size" => kind_of(String), "subject" => "An example message", "tag" => nil, "timestamp" => kind_of(Float) }, "inspection" => { "inspected" => false, "spam" => false, "spam_score" => 0.0, "threat" => false, "threat_details" => nil }, "plain_body" => message.plain_body, "html_body" => message.html_body, "attachments" => [], "headers" => message.headers, "raw_message" => Base64.encode64(message.raw_message), "activity_entries" => { "loads" => [], "clicks" => [] } }) end end context "when the status expansion is requested" do let(:expansions) { ["status"] } it "returns details about the message" do expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "success" expect(parsed_body["data"]).to match({ "id" => message.id, "token" => message.token, "status" => { "held" => false, "hold_expiry" => nil, "last_delivery_attempt" => nil, "status" => "Pending" } }) end end context "when the details expansion is requested" do let(:expansions) { ["details"] } it "returns details about the message" do expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "success" expect(parsed_body["data"]).to match({ "id" => message.id, "token" => message.token, "details" => { "bounce" => false, "bounce_for_id" => 0, "direction" => "outgoing", "mail_from" => "test@example.com", "message_id" => message.message_id, "rcpt_to" => "john@example.com", "received_with_ssl" => nil, "size" => kind_of(String), "subject" => "An example message", "tag" => nil, "timestamp" => kind_of(Float) } }) end end context "when the details expansion is requested" do let(:expansions) { ["inspection"] } it "returns details about the message" do expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "success" expect(parsed_body["data"]).to match({ "id" => message.id, "token" => message.token, "inspection" => { "inspected" => false, "spam" => false, "spam_score" => 0.0, "threat" => false, "threat_details" => nil } }) end end context "when the body expansions are requested" do let(:expansions) { %w[plain_body html_body] } it "returns details about the message" do expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "success" expect(parsed_body["data"]).to match({ "id" => message.id, "token" => message.token, "plain_body" => message.plain_body, "html_body" => message.html_body }) end end context "when the attachments expansions is requested" do let(:message) do MessageFactory.outgoing(server) do |_, mail| mail.attachments["example.txt"] = "hello world!" end end let(:expansions) { ["attachments"] } it "returns details about the message" do expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "success" expect(parsed_body["data"]).to match({ "id" => message.id, "token" => message.token, "attachments" => [ { "content_type" => "text/plain", "data" => Base64.encode64("hello world!"), "filename" => "example.txt", "hash" => Digest::SHA1.hexdigest("hello world!"), "size" => 12 }, ] }) end end context "when the headers expansions is requested" do let(:expansions) { ["headers"] } it "returns details about the message" do expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "success" expect(parsed_body["data"]).to match({ "id" => message.id, "token" => message.token, "headers" => message.headers }) end end context "when the raw_message expansions is requested" do let(:expansions) { ["raw_message"] } it "returns details about the message" do expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "success" expect(parsed_body["data"]).to match({ "id" => message.id, "token" => message.token, "raw_message" => Base64.encode64(message.raw_message) }) end end context "when the activity_entries expansions is requested" do let(:message) do MessageFactory.outgoing(server) do |msg| msg.create_load(double("request", ip: "1.2.3.4", user_agent: "user agent")) link = msg.create_link("https://example.com") link_id = msg.database.select(:links, where: { token: link }).first["id"] msg.database.insert(:clicks, { message_id: msg.id, link_id: link_id, ip_address: "1.2.3.4", user_agent: "user agent", timestamp: Time.now.to_f }) end end let(:expansions) { ["activity_entries"] } it "returns details about the message" do expect(response.status).to eq 200 parsed_body = JSON.parse(response.body) expect(parsed_body["status"]).to eq "success" expect(parsed_body["data"]).to match({ "id" => message.id, "token" => message.token, "activity_entries" => { "loads" => [{ "ip_address" => "1.2.3.4", "user_agent" => "user agent", "timestamp" => match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\z/) }], "clicks" => [{ "url" => "https://example.com", "ip_address" => "1.2.3.4", "user_agent" => "user agent", "timestamp" => match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\z/) }] } }) end end end end end end