Skip to content

Commit ac9ff5c

Browse files
committed
Add SnsSubscriptionConfirmer service for automatic SNS subscription confirmation
1 parent 76b8b51 commit ac9ff5c

2 files changed

Lines changed: 95 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
3+
require "uri"
4+
5+
class SnsSubscriptionConfirmer
6+
class ConfirmationError < StandardError; end
7+
8+
ALLOWED_HOST_REGEX = /\Asns\.[a-z0-9-]+\.amazonaws\.com\z/
9+
10+
def self.confirm!(subscribe_url)
11+
new(subscribe_url).confirm!
12+
end
13+
14+
def initialize(subscribe_url)
15+
@subscribe_url = subscribe_url
16+
end
17+
18+
def confirm!
19+
assert_url_safe!
20+
21+
response = HTTParty.get(@subscribe_url)
22+
unless response.success?
23+
raise ConfirmationError, "AWS SNS returned #{response.code} when confirming subscription"
24+
end
25+
response
26+
end
27+
28+
private
29+
def assert_url_safe!
30+
uri =
31+
begin
32+
URI.parse(@subscribe_url)
33+
rescue URI::InvalidURIError => e
34+
raise ConfirmationError, "Invalid SubscribeURL: #{e.message}"
35+
end
36+
37+
raise ConfirmationError, "SubscribeURL must be a URL" if uri.host.nil?
38+
raise ConfirmationError, "SubscribeURL must use https scheme" unless uri.scheme == "https"
39+
raise ConfirmationError, "SubscribeURL host #{uri.host.inspect} is not an AWS SNS host" unless uri.host.match?(ALLOWED_HOST_REGEX)
40+
end
41+
end
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe SnsSubscriptionConfirmer, type: :service do
6+
let(:valid_url) { "https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:...&Token=xyz" }
7+
let(:success_response) { instance_double(HTTParty::Response, code: 200, success?: true, body: "<xml/>") }
8+
9+
describe ".confirm!" do
10+
it "GETs the SubscribeURL for a valid AWS SNS host" do
11+
expect(HTTParty).to receive(:get).with(valid_url).and_return(success_response)
12+
described_class.confirm!(valid_url)
13+
end
14+
15+
it "accepts SNS hosts from any region" do
16+
urls = [
17+
"https://sns.us-east-1.amazonaws.com/?Token=x",
18+
"https://sns.eu-west-2.amazonaws.com/?Token=x",
19+
"https://sns.ap-southeast-1.amazonaws.com/?Token=x"
20+
]
21+
urls.each do |url|
22+
expect(HTTParty).to receive(:get).with(url).and_return(success_response)
23+
described_class.confirm!(url)
24+
end
25+
end
26+
27+
it "raises ConfirmationError for a non-AWS host" do
28+
expect {
29+
described_class.confirm!("https://evil.example.com/?Token=x")
30+
}.to raise_error(SnsSubscriptionConfirmer::ConfirmationError, /host/i)
31+
end
32+
33+
it "raises ConfirmationError for a non-HTTPS scheme" do
34+
expect {
35+
described_class.confirm!("http://sns.us-west-2.amazonaws.com/?Token=x")
36+
}.to raise_error(SnsSubscriptionConfirmer::ConfirmationError, /https/i)
37+
end
38+
39+
it "raises ConfirmationError when the URL is malformed" do
40+
expect {
41+
described_class.confirm!("not a url")
42+
}.to raise_error(SnsSubscriptionConfirmer::ConfirmationError)
43+
end
44+
45+
it "raises ConfirmationError when AWS returns a non-2xx response" do
46+
failure_response = instance_double(HTTParty::Response, code: 403, success?: false, body: "Forbidden")
47+
allow(HTTParty).to receive(:get).and_return(failure_response)
48+
49+
expect {
50+
described_class.confirm!(valid_url)
51+
}.to raise_error(SnsSubscriptionConfirmer::ConfirmationError, /403/)
52+
end
53+
end
54+
end

0 commit comments

Comments
 (0)