about summary refs log tree commit diff
path: root/spec
diff options
context:
space:
mode:
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/accounts_controller_spec.rb2
-rw-r--r--spec/controllers/api/activitypub/activities_controller_spec.rb69
-rw-r--r--spec/controllers/api/activitypub/notes_controller_spec.rb73
-rw-r--r--spec/controllers/api/activitypub/outbox_controller_spec.rb156
-rw-r--r--spec/controllers/api/push_controller_spec.rb1
-rw-r--r--spec/controllers/api/web/push_subscriptions_controller_spec.rb81
-rw-r--r--spec/controllers/concerns/signature_verification_spec.rb74
-rw-r--r--spec/controllers/well_known/webfinger_controller_spec.rb39
-rw-r--r--spec/fabricators/web_push_subscription_fabricator.rb5
-rw-r--r--spec/helpers/activitystreams2_builder_helper_spec.rb15
-rw-r--r--spec/helpers/emoji_helper_spec.rb15
-rw-r--r--spec/helpers/http_helper_spec.rb13
-rw-r--r--spec/helpers/routing_helper.rb5
-rw-r--r--spec/javascript/components/emojify.test.js52
-rw-r--r--spec/lib/feed_manager_spec.rb7
-rw-r--r--spec/lib/request_spec.rb54
-rw-r--r--spec/models/tag_spec.rb27
-rw-r--r--spec/models/web/push_subscription_spec.rb28
-rw-r--r--spec/views/stream_entries/show.html.haml_spec.rb4
-rw-r--r--spec/workers/pubsubhubbub/confirmation_worker_spec.rb2
-rw-r--r--spec/workers/pubsubhubbub/delivery_worker_spec.rb2
21 files changed, 323 insertions, 401 deletions
diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb
index 447e2dd53..d61c8c9bd 100644
--- a/spec/controllers/accounts_controller_spec.rb
+++ b/spec/controllers/accounts_controller_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe AccountsController, type: :controller do
 
     context 'activitystreams2' do
       before do
-        get :show, params: { username: alice.username }, format: 'activitystreams2'
+        get :show, params: { username: alice.username }, format: 'json'
       end
 
       it 'assigns @account' do
diff --git a/spec/controllers/api/activitypub/activities_controller_spec.rb b/spec/controllers/api/activitypub/activities_controller_spec.rb
deleted file mode 100644
index 07df28ac2..000000000
--- a/spec/controllers/api/activitypub/activities_controller_spec.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe Api::ActivityPub::ActivitiesController, type: :controller do
-  render_views
-
-  let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
-
-  describe 'GET #show' do
-    describe 'normal status' do
-      public_status = nil
-
-      before do
-        public_status = Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public)
-
-        @request.env['HTTP_ACCEPT'] = 'application/activity+json'
-        get :show_status, params: { id: public_status.id }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns http success' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('type' => 'Create')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('type' => 'Create')
-        expect(json_data).to include('object' => api_activitypub_note_url(public_status))
-        expect(json_data).to include('url' => TagManager.instance.url_for(public_status))
-      end
-    end
-
-    describe 'reblog' do
-      original = nil
-      reblog = nil
-
-      before do
-        original = Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public)
-        reblog = Fabricate(:status, account: user.account, reblog_of_id: original.id, visibility: :public)
-
-        @request.env['HTTP_ACCEPT'] = 'application/activity+json'
-        get :show_status, params: { id: reblog.id }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns http success' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('type' => 'Announce')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('type' => 'Announce')
-        expect(json_data).to include('object' => api_activitypub_status_url(original))
-        expect(json_data).to include('url' => TagManager.instance.url_for(reblog))
-      end
-    end
-  end
-end
diff --git a/spec/controllers/api/activitypub/notes_controller_spec.rb b/spec/controllers/api/activitypub/notes_controller_spec.rb
deleted file mode 100644
index a0f05dc65..000000000
--- a/spec/controllers/api/activitypub/notes_controller_spec.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe Api::ActivityPub::NotesController, type: :controller do
-  render_views
-
-  let(:user_alice)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
-  let(:user_bob)  { Fabricate(:user, account: Fabricate(:account, username: 'bob')) }
-
-  describe 'GET #show' do
-    describe 'normal status' do
-      public_status = nil
-
-      before do
-        public_status = Fabricate(:status, account: user_alice.account, text: 'Hello world', visibility: :public)
-
-        @request.env['HTTP_ACCEPT'] = 'application/activity+json'
-        get :show, params: { id: public_status.id }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns http success' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('type' => 'Note')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('name' => 'Hello world')
-        expect(json_data).to include('content' => 'Hello world')
-        expect(json_data).to include('published')
-        expect(json_data).to include('url' => TagManager.instance.url_for(public_status))
-      end
-    end
-
-    describe 'reply' do
-      original = nil
-      reply = nil
-
-      before do
-        original = Fabricate(:status, account: user_alice.account, text: 'Hello world', visibility: :public)
-        reply = Fabricate(:status, account: user_bob.account, text: 'Hello world', in_reply_to_id: original.id, visibility: :public)
-
-        @request.env['HTTP_ACCEPT'] = 'application/activity+json'
-        get :show, params: { id: reply.id }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns http success' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('type' => 'Note')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('name' => 'Hello world')
-        expect(json_data).to include('content' => 'Hello world')
-        expect(json_data).to include('published')
-        expect(json_data).to include('url' => TagManager.instance.url_for(reply))
-        expect(json_data).to include('inReplyTo' => api_activitypub_note_url(original))
-      end
-    end
-  end
-end
diff --git a/spec/controllers/api/activitypub/outbox_controller_spec.rb b/spec/controllers/api/activitypub/outbox_controller_spec.rb
deleted file mode 100644
index 049cf451d..000000000
--- a/spec/controllers/api/activitypub/outbox_controller_spec.rb
+++ /dev/null
@@ -1,156 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe Api::ActivityPub::OutboxController, type: :controller do
-  render_views
-
-  let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
-
-  describe 'GET #show' do
-    before do
-      @request.headers['ACCEPT'] = 'application/activity+json'
-    end
-
-    describe 'collection with small number of statuses' do
-      public_status = nil
-
-      before do
-        public_status = Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :private)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :unlisted)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :direct)
-
-        get :show, params: { id: user.account.id }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns AS2 JSON body' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('type' => 'OrderedCollection')
-        expect(json_data).to include('totalItems' => 1)
-        expect(json_data).to include('current')
-        expect(json_data).to include('first')
-        expect(json_data).to include('last')
-      end
-    end
-
-    describe 'collection with large number of statuses' do
-      before do
-        30.times do
-          Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public)
-        end
-
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :private)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :unlisted)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :direct)
-
-        get :show, params: { id: user.account.id }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns AS2 JSON body' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('type' => 'OrderedCollection')
-        expect(json_data).to include('totalItems' => 30)
-        expect(json_data).to include('current')
-        expect(json_data).to include('first')
-        expect(json_data).to include('last')
-      end
-    end
-
-    describe 'page with small number of statuses' do
-      statuses = []
-
-      before do
-        5.times do
-          statuses << Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public)
-        end
-
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :private)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :unlisted)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :direct)
-
-        get :show, params: { id: user.account.id, max_id: statuses.last.id + 1 }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns AS2 JSON body' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('type' => 'OrderedCollectionPage')
-        expect(json_data).to include('partOf')
-        expect(json_data).to include('items')
-        expect(json_data['items'].length).to eq(5)
-        expect(json_data).to include('prev')
-        expect(json_data).to include('next')
-        expect(json_data).to include('current')
-        expect(json_data).to include('first')
-        expect(json_data).to include('last')
-      end
-    end
-
-    describe 'page with large number of statuses' do
-      statuses = []
-
-      before do
-        30.times do
-          statuses << Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public)
-        end
-
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :private)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :unlisted)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :direct)
-
-        get :show, params: { id: user.account.id, max_id: statuses.last.id + 1 }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns AS2 JSON body' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('type' => 'OrderedCollectionPage')
-        expect(json_data).to include('partOf')
-        expect(json_data).to include('items')
-        expect(json_data['items'].length).to eq(20)
-        expect(json_data).to include('prev')
-        expect(json_data).to include('next')
-        expect(json_data).to include('current')
-        expect(json_data).to include('first')
-        expect(json_data).to include('last')
-      end
-    end
-  end
-end
diff --git a/spec/controllers/api/push_controller_spec.rb b/spec/controllers/api/push_controller_spec.rb
index 18bfa70e5..647698bd1 100644
--- a/spec/controllers/api/push_controller_spec.rb
+++ b/spec/controllers/api/push_controller_spec.rb
@@ -21,6 +21,7 @@ RSpec.describe Api::PushController, type: :controller do
           'https://callback.host/api',
           'as1234df',
           '3600',
+          nil
         )
         expect(response).to have_http_status(:success)
       end
diff --git a/spec/controllers/api/web/push_subscriptions_controller_spec.rb b/spec/controllers/api/web/push_subscriptions_controller_spec.rb
new file mode 100644
index 000000000..871176a07
--- /dev/null
+++ b/spec/controllers/api/web/push_subscriptions_controller_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Api::Web::PushSubscriptionsController do
+  render_views
+
+  let(:user) { Fabricate(:user) }
+
+  let(:create_payload) do
+    {
+      data: {
+        endpoint: 'https://fcm.googleapis.com/fcm/send/fiuH06a27qE:APA91bHnSiGcLwdaxdyqVXNDR9w1NlztsHb6lyt5WDKOC_Z_Q8BlFxQoR8tWFSXUIDdkyw0EdvxTu63iqamSaqVSevW5LfoFwojws8XYDXv_NRRLH6vo2CdgiN4jgHv5VLt2A8ah6lUX',
+        keys: {
+          p256dh: 'BEm_a0bdPDhf0SOsrnB2-ategf1hHoCnpXgQsFj5JCkcoMrMt2WHoPfEYOYPzOIs9mZE8ZUaD7VA5vouy0kEkr8=',
+          auth: 'eH_C8rq2raXqlcBVDa1gLg==',
+        },
+      }
+    }
+  end
+
+  let(:alerts_payload) do
+    {
+      data: {
+        alerts: {
+          follow: true,
+          favourite: false,
+          reblog: true,
+          mention: false,
+        }
+      }
+    }
+  end
+
+  describe 'POST #create' do
+    it 'saves push subscriptions' do
+      sign_in(user)
+
+      stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200)
+
+      post :create, format: :json, params: create_payload
+
+      user.reload
+
+      push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint])
+
+      expect(push_subscription['endpoint']).to eq(create_payload[:data][:endpoint])
+      expect(push_subscription['key_p256dh']).to eq(create_payload[:data][:keys][:p256dh])
+      expect(push_subscription['key_auth']).to eq(create_payload[:data][:keys][:auth])
+    end
+
+    it 'sends welcome notification' do
+      sign_in(user)
+
+      stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200)
+
+      post :create, format: :json, params: create_payload
+    end
+  end
+
+  describe 'PUT #update' do
+    it 'changes alert settings' do
+      sign_in(user)
+
+      stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200)
+
+      post :create, format: :json, params: create_payload
+
+      alerts_payload[:id] = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint]).id
+
+      put :update, format: :json, params: alerts_payload
+
+      push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint])
+
+      expect(push_subscription.data['follow']).to eq(alerts_payload[:data][:follow])
+      expect(push_subscription.data['favourite']).to eq(alerts_payload[:data][:favourite])
+      expect(push_subscription.data['reblog']).to eq(alerts_payload[:data][:reblog])
+      expect(push_subscription.data['mention']).to eq(alerts_payload[:data][:mention])
+    end
+  end
+end
diff --git a/spec/controllers/concerns/signature_verification_spec.rb b/spec/controllers/concerns/signature_verification_spec.rb
new file mode 100644
index 000000000..b371795ab
--- /dev/null
+++ b/spec/controllers/concerns/signature_verification_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe ApplicationController, type: :controller do
+  controller do
+    include SignatureVerification
+
+    def success
+      head 200
+    end
+
+    def alternative_success
+      head 200
+    end
+  end
+
+  before do
+    routes.draw { get 'success' => 'anonymous#success' }
+  end
+
+  context 'without signature header' do
+    before do
+      get :success
+    end
+
+    describe '#signed_request?' do
+      it 'returns false' do
+        expect(controller.signed_request?).to be false
+      end
+    end
+
+    describe '#signed_request_account' do
+      it 'returns nil' do
+        expect(controller.signed_request_account).to be_nil
+      end
+    end
+  end
+
+  context 'with signature header' do
+    let!(:author) { Fabricate(:account) }
+
+    before do
+      get :success
+
+      fake_request = Request.new(:get, request.url)
+      fake_request.on_behalf_of(author)
+
+      request.headers.merge!(fake_request.headers)
+    end
+
+    describe '#signed_request?' do
+      it 'returns true' do
+        expect(controller.signed_request?).to be true
+      end
+    end
+
+    describe '#signed_request_account' do
+      it 'returns an account' do
+        expect(controller.signed_request_account).to eq author
+      end
+
+      it 'returns nil when path does not match' do
+        request.path = '/alternative-path'
+        expect(controller.signed_request_account).to be_nil
+      end
+
+      it 'returns nil when method does not match' do
+        post :success
+        expect(controller.signed_request_account).to be_nil
+      end
+    end
+  end
+end
diff --git a/spec/controllers/well_known/webfinger_controller_spec.rb b/spec/controllers/well_known/webfinger_controller_spec.rb
index 3699efb56..466f87c45 100644
--- a/spec/controllers/well_known/webfinger_controller_spec.rb
+++ b/spec/controllers/well_known/webfinger_controller_spec.rb
@@ -9,7 +9,7 @@ describe WellKnown::WebfingerController, type: :controller do
     end
 
     before do
-      alice.private_key = <<PEM
+      alice.private_key = <<-PEM
 -----BEGIN RSA PRIVATE KEY-----
 MIICXQIBAAKBgQDHgPoPJlrfMZrVcuF39UbVssa8r4ObLP3dYl9Y17Mgp5K4mSYD
 R/Y2ag58tSi6ar2zM3Ze3QYsNfTq0NqN1g89eAu0MbSjWqpOsgntRPJiFuj3hai2
@@ -27,7 +27,7 @@ FTX8IvYBNTbpEttc1VCf/0ccnNpfb0CrFNSPWxRj7t7D
 -----END RSA PRIVATE KEY-----
 PEM
 
-      alice.public_key = <<PEM
+      alice.public_key = <<-PEM
 -----BEGIN PUBLIC KEY-----
 MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHgPoPJlrfMZrVcuF39UbVssa8
 r4ObLP3dYl9Y17Mgp5K4mSYDR/Y2ag58tSi6ar2zM3Ze3QYsNfTq0NqN1g89eAu0
@@ -48,29 +48,23 @@ PEM
     it 'returns JSON when account can be found' do
       get :show, params: { resource: alice.to_webfinger_s }, format: :json
 
+      json = body_as_json
+
       expect(response).to have_http_status(:success)
       expect(response.content_type).to eq 'application/jrd+json'
-      expect(response.body).to eq "{\"subject\":\"acct:alice@cb6e6126.ngrok.io\",\"aliases\":[\"https://cb6e6126.ngrok.io/@alice\",\"https://cb6e6126.ngrok.io/users/alice\"],\"links\":[{\"rel\":\"http://webfinger.net/rel/profile-page\",\"type\":\"text/html\",\"href\":\"https://cb6e6126.ngrok.io/@alice\"},{\"rel\":\"http://schemas.google.com/g/2010#updates-from\",\"type\":\"application/atom+xml\",\"href\":\"https://cb6e6126.ngrok.io/users/alice.atom\"},{\"rel\":\"self\",\"type\":\"application/activity+json\",\"href\":\"https://cb6e6126.ngrok.io/@alice\"},{\"rel\":\"salmon\",\"href\":\"#{api_salmon_url(alice.id)}\"},{\"rel\":\"magic-public-key\",\"href\":\"data:application/magic-public-key,RSA.x4D6DyZa3zGa1XLhd_VG1bLGvK-Dmyz93WJfWNezIKeSuJkmA0f2NmoOfLUoumq9szN2Xt0GLDX06tDajdYPPXgLtDG0o1qqTrIJ7UTyYhbo94Wotl9iJvEwa5IjP1Mn00YJ_KvFrzKCm15PC7up6r-NtHsqoYS8X1KAqcbnptU=.AQAB\"},{\"rel\":\"http://ostatus.org/schema/1.0/subscribe\",\"template\":\"https://cb6e6126.ngrok.io/authorize_follow?acct={uri}\"}]}"
+      expect(json[:subject]).to eq 'acct:alice@cb6e6126.ngrok.io'
+      expect(json[:aliases]).to include('https://cb6e6126.ngrok.io/@alice', 'https://cb6e6126.ngrok.io/users/alice')
     end
 
     it 'returns JSON when account can be found' do
       get :show, params: { resource: alice.to_webfinger_s }, format: :xml
 
+      xml = Nokogiri::XML(response.body)
+
       expect(response).to have_http_status(:success)
       expect(response.content_type).to eq 'application/xrd+xml'
-      expect(response.body).to eq <<"XML"
-<?xml version="1.0"?>
-<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
-  <Subject>acct:alice@cb6e6126.ngrok.io</Subject>
-  <Alias>https://cb6e6126.ngrok.io/@alice</Alias>
-  <Alias>https://cb6e6126.ngrok.io/users/alice</Alias>
-  <Link rel="http://webfinger.net/rel/profile-page" type="text/html" href="https://cb6e6126.ngrok.io/@alice"/>
-  <Link rel="http://schemas.google.com/g/2010#updates-from" type="application/atom+xml" href="https://cb6e6126.ngrok.io/users/alice.atom"/>
-  <Link rel="salmon" href="#{api_salmon_url(alice.id)}"/>
-  <Link rel="magic-public-key" href="data:application/magic-public-key,RSA.x4D6DyZa3zGa1XLhd_VG1bLGvK-Dmyz93WJfWNezIKeSuJkmA0f2NmoOfLUoumq9szN2Xt0GLDX06tDajdYPPXgLtDG0o1qqTrIJ7UTyYhbo94Wotl9iJvEwa5IjP1Mn00YJ_KvFrzKCm15PC7up6r-NtHsqoYS8X1KAqcbnptU=.AQAB"/>
-  <Link rel="http://ostatus.org/schema/1.0/subscribe" template="https://cb6e6126.ngrok.io/authorize_follow?acct={uri}"/>
-</XRD>
-XML
+      expect(xml.at_xpath('//xmlns:Subject').content).to eq 'acct:alice@cb6e6126.ngrok.io'
+      expect(xml.xpath('//xmlns:Alias').map(&:content)).to include('https://cb6e6126.ngrok.io/@alice', 'https://cb6e6126.ngrok.io/users/alice')
     end
 
     it 'returns http not found when account cannot be found' do
@@ -80,19 +74,22 @@ XML
     end
 
     it 'returns JSON when account can be found with alternate domains' do
-      Rails.configuration.x.alternate_domains = ["foo.org"]
-      username, domain = alice.to_webfinger_s.split("@")
+      Rails.configuration.x.alternate_domains = ['foo.org']
+      username, = alice.to_webfinger_s.split('@')
 
       get :show, params: { resource: "#{username}@foo.org" }, format: :json
 
+      json = body_as_json
+
       expect(response).to have_http_status(:success)
       expect(response.content_type).to eq 'application/jrd+json'
-      expect(response.body).to eq "{\"subject\":\"acct:alice@cb6e6126.ngrok.io\",\"aliases\":[\"https://cb6e6126.ngrok.io/@alice\",\"https://cb6e6126.ngrok.io/users/alice\"],\"links\":[{\"rel\":\"http://webfinger.net/rel/profile-page\",\"type\":\"text/html\",\"href\":\"https://cb6e6126.ngrok.io/@alice\"},{\"rel\":\"http://schemas.google.com/g/2010#updates-from\",\"type\":\"application/atom+xml\",\"href\":\"https://cb6e6126.ngrok.io/users/alice.atom\"},{\"rel\":\"self\",\"type\":\"application/activity+json\",\"href\":\"https://cb6e6126.ngrok.io/@alice\"},{\"rel\":\"salmon\",\"href\":\"#{api_salmon_url(alice.id)}\"},{\"rel\":\"magic-public-key\",\"href\":\"data:application/magic-public-key,RSA.x4D6DyZa3zGa1XLhd_VG1bLGvK-Dmyz93WJfWNezIKeSuJkmA0f2NmoOfLUoumq9szN2Xt0GLDX06tDajdYPPXgLtDG0o1qqTrIJ7UTyYhbo94Wotl9iJvEwa5IjP1Mn00YJ_KvFrzKCm15PC7up6r-NtHsqoYS8X1KAqcbnptU=.AQAB\"},{\"rel\":\"http://ostatus.org/schema/1.0/subscribe\",\"template\":\"https://cb6e6126.ngrok.io/authorize_follow?acct={uri}\"}]}"
+      expect(json[:subject]).to eq 'acct:alice@cb6e6126.ngrok.io'
+      expect(json[:aliases]).to include('https://cb6e6126.ngrok.io/@alice', 'https://cb6e6126.ngrok.io/users/alice')
     end
 
     it 'returns http not found when account can not be found with alternate domains' do
-      Rails.configuration.x.alternate_domains = ["foo.org"]
-      username, domain = alice.to_webfinger_s.split("@")
+      Rails.configuration.x.alternate_domains = ['foo.org']
+      username, = alice.to_webfinger_s.split('@')
 
       get :show, params: { resource: "#{username}@bar.org" }, format: :json
 
diff --git a/spec/fabricators/web_push_subscription_fabricator.rb b/spec/fabricators/web_push_subscription_fabricator.rb
new file mode 100644
index 000000000..72d11b77c
--- /dev/null
+++ b/spec/fabricators/web_push_subscription_fabricator.rb
@@ -0,0 +1,5 @@
+Fabricator(:web_push_subscription) do
+  endpoint   Faker::Internet.url
+  key_p256dh Faker::Internet.password
+  key_auth   Faker::Internet.password
+end
diff --git a/spec/helpers/activitystreams2_builder_helper_spec.rb b/spec/helpers/activitystreams2_builder_helper_spec.rb
deleted file mode 100644
index 612ce6ad2..000000000
--- a/spec/helpers/activitystreams2_builder_helper_spec.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Activitystreams2BuilderHelper, type: :helper do
-  it 'returns display name if present' do
-    account = Fabricate(:account, display_name: 'display name', username: 'username')
-    expect(account_name(account)).to eq 'display name'
-  end
-
-  it 'returns username if display name is not present' do
-    account = Fabricate(:account, display_name: '', username: 'username')
-    expect(account_name(account)).to eq 'username'
-  end
-end
diff --git a/spec/helpers/emoji_helper_spec.rb b/spec/helpers/emoji_helper_spec.rb
new file mode 100644
index 000000000..1eedfb719
--- /dev/null
+++ b/spec/helpers/emoji_helper_spec.rb
@@ -0,0 +1,15 @@
+require 'rails_helper'
+
+RSpec.describe EmojiHelper, type: :helper do
+  describe '#emojify' do
+    it 'converts shortcodes to unicode' do
+      text = ':book: Book'
+      expect(emojify(text)).to eq '📖 Book'
+    end
+
+    it 'does not convert shortcodes that are part of a string into unicode' do
+      text = ':see_no_evil::hear_no_evil::speak_no_evil:'
+      expect(emojify(text)).to eq text
+    end
+  end
+end
diff --git a/spec/helpers/http_helper_spec.rb b/spec/helpers/http_helper_spec.rb
deleted file mode 100644
index b8e31b8e6..000000000
--- a/spec/helpers/http_helper_spec.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe HttpHelper do
-  describe 'http_client' do
-    it 'returns HTTP::Client with default options' do
-      options = helper.http_client.default_options
-      expect(options.headers['User-Agent']).to match /.+ \(Mastodon\/.+;\ \+http:\/\/cb6e6126\.ngrok\.io\/\)/
-      expect(options.timeout_options).to eq read_timeout: 10, write_timeout: 10, connect_timeout: 10
-    end
-  end
-end
diff --git a/spec/helpers/routing_helper.rb b/spec/helpers/routing_helper.rb
deleted file mode 100644
index 3cd397397..000000000
--- a/spec/helpers/routing_helper.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe RoutingHelper, type: :helper do
-
-end
diff --git a/spec/javascript/components/emojify.test.js b/spec/javascript/components/emojify.test.js
index 3e8b25af9..2874bb56d 100644
--- a/spec/javascript/components/emojify.test.js
+++ b/spec/javascript/components/emojify.test.js
@@ -2,32 +2,6 @@ import { expect } from 'chai';
 import emojify from '../../../app/javascript/mastodon/emoji';
 
 describe('emojify', () => {
-  it('does a basic emojify', () => {
-    expect(emojify(':smile:')).to.equal(
-      '<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" />');
-  });
-
-  it('does a double emojify', () => {
-    expect(emojify(':smile: and :wink:')).to.equal(
-      '<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" /> and <img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" />');
-  });
-
-  it('works with random colons', () => {
-    expect(emojify(':smile: : :wink:')).to.equal(
-      '<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" /> : <img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" />');
-    expect(emojify(':smile::::wink:')).to.equal(
-      '<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" />::<img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" />');
-    expect(emojify(':smile:::::wink:')).to.equal(
-      '<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" />:::<img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" />');
-  });
-
-  it('works with tags', () => {
-    expect(emojify('<p>:smile:</p>')).to.equal(
-      '<p><img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" /></p>');
-    expect(emojify('<p>:smile:</p> and <p>:wink:</p>')).to.equal(
-      '<p><img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" /></p> and <p><img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" /></p>');
-  });
-
   it('ignores unknown shortcodes', () => {
     expect(emojify(':foobarbazfake:')).to.equal(':foobarbazfake:');
   });
@@ -46,38 +20,28 @@ describe('emojify', () => {
     expect(emojify(':smile')).to.equal(':smile');
   });
 
-  it('does two emoji next to each other', () => {
-    expect(emojify(':smile::wink:')).to.equal(
-      '<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" /><img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" />');
-  });
-
   it('does unicode', () => {
     expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).to.equal(
-      '<img draggable="false" class="emojione" alt="👩‍👩‍👦‍👦" title=":family_wwbb:" src="/emoji/1f469-1f469-1f466-1f466.svg" />');
+      '<img draggable="false" class="emojione" alt="👩‍👩‍👦‍👦" src="/emoji/1f469-1f469-1f466-1f466.svg" />');
     expect(emojify('\uD83D\uDC68\uD83D\uDC69\uD83D\uDC67\uD83D\uDC67')).to.equal(
-      '<img draggable="false" class="emojione" alt="👨👩👧👧" title=":family_mwgg:" src="/emoji/1f468-1f469-1f467-1f467.svg" />');
-    expect(emojify('\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66')).to.equal('<img draggable="false" class="emojione" alt="👩👩👦" title=":family_wwb:" src="/emoji/1f469-1f469-1f466.svg" />');
+      '<img draggable="false" class="emojione" alt="👨👩👧👧" src="/emoji/1f468-1f469-1f467-1f467.svg" />');
+    expect(emojify('\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66')).to.equal('<img draggable="false" class="emojione" alt="👩👩👦" src="/emoji/1f469-1f469-1f466.svg" />');
     expect(emojify('\u2757')).to.equal(
-      '<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
+      '<img draggable="false" class="emojione" alt="❗" src="/emoji/2757.svg" />');
   });
 
   it('does multiple unicode', () => {
     expect(emojify('\u2757 #\uFE0F\u20E3')).to.equal(
-      '<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/0023-20e3.svg" />');
+      '<img draggable="false" class="emojione" alt="❗" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" src="/emoji/0023-20e3.svg" />');
     expect(emojify('\u2757#\uFE0F\u20E3')).to.equal(
-      '<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/0023-20e3.svg" />');
+      '<img draggable="false" class="emojione" alt="❗" src="/emoji/2757.svg" /><img draggable="false" class="emojione" alt="#️⃣" src="/emoji/0023-20e3.svg" />');
     expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).to.equal(
-      '<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/0023-20e3.svg" /> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
+      '<img draggable="false" class="emojione" alt="❗" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" src="/emoji/0023-20e3.svg" /> <img draggable="false" class="emojione" alt="❗" src="/emoji/2757.svg" />');
     expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).to.equal(
-      'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/0023-20e3.svg" /> bar');
-  });
-
-  it('does mixed unicode and shortnames', () => {
-    expect(emojify(':smile:#\uFE0F\u20E3:wink:\u2757')).to.equal('<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" /><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/0023-20e3.svg" /><img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" /><img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
+      'foo <img draggable="false" class="emojione" alt="❗" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" src="/emoji/0023-20e3.svg" /> bar');
   });
 
   it('ignores unicode inside of tags', () => {
     expect(emojify('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>')).to.equal('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>');
   });
-
 });
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index 4bdc96866..22439cf35 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -81,6 +81,13 @@ RSpec.describe FeedManager do
         expect(FeedManager.instance.filter?(:home, reply, bob.id)).to be true
       end
 
+      it 'returns true for the second reply by followee to a non-federated status' do
+        reply        = Fabricate(:status, text: 'Reply 1', reply: true, account: alice)
+        second_reply = Fabricate(:status, text: 'Reply 2', thread: reply, account: alice)
+        bob.follow!(alice)
+        expect(FeedManager.instance.filter?(:home, second_reply, bob.id)).to be true
+      end
+
       it 'returns false for status by followee mentioning another account' do
         bob.follow!(alice)
         status = PostStatusService.new.call(alice, 'Hey @jeff')
diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb
new file mode 100644
index 000000000..782f14b18
--- /dev/null
+++ b/spec/lib/request_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Request do
+  subject { Request.new(:get, 'http://example.com') }
+
+  describe '#headers' do
+    it 'returns user agent' do
+      expect(subject.headers['User-Agent']).to be_present
+    end
+
+    it 'returns the date header' do
+      expect(subject.headers['Date']).to be_present
+    end
+
+    it 'returns the host header' do
+      expect(subject.headers['Host']).to be_present
+    end
+
+    it 'does not return virtual request-target header' do
+      expect(subject.headers['(request-target)']).to be_nil
+    end
+  end
+
+  describe '#on_behalf_of' do
+    it 'when used, adds signature header' do
+      subject.on_behalf_of(Fabricate(:account))
+      expect(subject.headers['Signature']).to be_present
+    end
+  end
+
+  describe '#add_headers' do
+    it 'adds headers to the request' do
+      subject.add_headers('Test' => 'Foo')
+      expect(subject.headers['Test']).to eq 'Foo'
+    end
+  end
+
+  describe '#perform' do
+    before do
+      stub_request(:get, 'http://example.com')
+      subject.perform
+    end
+
+    it 'executes a HTTP request' do
+      expect(a_request(:get, 'http://example.com')).to have_been_made.once
+    end
+
+    it 'sets headers' do
+      expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made
+    end
+  end
+end
diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb
index 7c574eabe..f727fa1dd 100644
--- a/spec/models/tag_spec.rb
+++ b/spec/models/tag_spec.rb
@@ -1,6 +1,24 @@
 require 'rails_helper'
 
 RSpec.describe Tag, type: :model do
+  describe 'validations' do
+    it 'invalid with #' do
+      expect(Tag.new(name: '#hello_world')).to_not be_valid
+    end
+
+    it 'invalid with .' do
+      expect(Tag.new(name: '.abcdef123')).to_not be_valid
+    end
+
+    it 'invalid with spaces' do
+      expect(Tag.new(name: 'hello world')).to_not be_valid
+    end
+
+    it 'valid with aesthetic' do
+      expect(Tag.new(name: 'aesthetic')).to be_valid
+    end
+  end
+
   describe 'HASHTAG_RE' do
     subject { Tag::HASHTAG_RE }
 
@@ -27,6 +45,15 @@ RSpec.describe Tag, type: :model do
       expect(results).to eq [tag]
     end
 
+    it 'finds tag records in case insensitive' do
+      tag = Fabricate(:tag, name: "MATCH")
+      _miss_tag = Fabricate(:tag, name: "miss")
+
+      results = Tag.search_for("match")
+
+      expect(results).to eq [tag]
+    end
+
     it 'finds the exact matching tag as the first item' do
       similar_tag = Fabricate(:tag, name: "matchlater")
       tag = Fabricate(:tag, name: "match")
diff --git a/spec/models/web/push_subscription_spec.rb b/spec/models/web/push_subscription_spec.rb
new file mode 100644
index 000000000..574da55ac
--- /dev/null
+++ b/spec/models/web/push_subscription_spec.rb
@@ -0,0 +1,28 @@
+require 'rails_helper'
+
+RSpec.describe Web::PushSubscription, type: :model do
+  let(:alerts) { { mention: true, reblog: false, follow: true, follow_request: false, favourite: true } }
+  let(:payload_no_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd').as_payload }
+  let(:payload_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd', data: { alerts: alerts }).as_payload }
+  let(:push_subscription) { Web::PushSubscription.new(data: { alerts: alerts }) }
+
+  describe '#as_payload' do
+    it 'only returns id and endpoint' do
+      expect(payload_no_alerts.keys).to eq [:id, :endpoint]
+    end
+
+    it 'returns alerts if set' do
+      expect(payload_alerts.keys).to eq [:id, :endpoint, :alerts]
+    end
+  end
+
+  describe '#pushable?' do
+    it 'obeys alert settings' do
+      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Mention'))).to eq true
+      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Status'))).to eq false
+      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Follow'))).to eq true
+      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'FollowRequest'))).to eq false
+      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Favourite'))).to eq true
+    end
+  end
+end
diff --git a/spec/views/stream_entries/show.html.haml_spec.rb b/spec/views/stream_entries/show.html.haml_spec.rb
index cc380e6ea..6cc3b117a 100644
--- a/spec/views/stream_entries/show.html.haml_spec.rb
+++ b/spec/views/stream_entries/show.html.haml_spec.rb
@@ -27,7 +27,7 @@ describe 'stream_entries/show.html.haml', without_verify_partial_doubles: true d
 
     render
 
-    mf2 = Microformats2.parse(rendered)
+    mf2 = Microformats.parse(rendered)
 
     expect(mf2.entry.name.to_s).to eq status.text
     expect(mf2.entry.url.to_s).not_to be_empty
@@ -53,7 +53,7 @@ describe 'stream_entries/show.html.haml', without_verify_partial_doubles: true d
 
     render
 
-    mf2 = Microformats2.parse(rendered)
+    mf2 = Microformats.parse(rendered)
 
     expect(mf2.entry.name.to_s).to eq reply.text
     expect(mf2.entry.url.to_s).not_to be_empty
diff --git a/spec/workers/pubsubhubbub/confirmation_worker_spec.rb b/spec/workers/pubsubhubbub/confirmation_worker_spec.rb
index 1199d5801..8f66b4520 100644
--- a/spec/workers/pubsubhubbub/confirmation_worker_spec.rb
+++ b/spec/workers/pubsubhubbub/confirmation_worker_spec.rb
@@ -83,6 +83,6 @@ describe Pubsubhubbub::ConfirmationWorker do
   end
 
   def http_headers
-    { 'Connection' => 'close', 'Host' => 'example.com', 'User-Agent' => 'Mastodon/PubSubHubbub' }
+    { 'Connection' => 'close', 'Host' => 'example.com', 'User-Agent' => 'http.rb/2.2.2 (Mastodon/1.4.7; +https://cb6e6126.ngrok.io/)' }
   end
 end
diff --git a/spec/workers/pubsubhubbub/delivery_worker_spec.rb b/spec/workers/pubsubhubbub/delivery_worker_spec.rb
index 081dfa41c..a83245786 100644
--- a/spec/workers/pubsubhubbub/delivery_worker_spec.rb
+++ b/spec/workers/pubsubhubbub/delivery_worker_spec.rb
@@ -59,7 +59,7 @@ describe Pubsubhubbub::DeliveryWorker do
         'Content-Type' => 'application/atom+xml',
         'Host' => 'example.com',
         'Link' => "<https://#{Rails.configuration.x.local_domain}/api/push>; rel=\"hub\", <https://#{Rails.configuration.x.local_domain}/users/#{subscription.account.username}.atom>; rel=\"self\"",
-        'User-Agent' => 'Mastodon/PubSubHubbub',
+        'User-Agent' => 'http.rb/2.2.2 (Mastodon/1.4.7; +https://cb6e6126.ngrok.io/)',
       }.tap do |basic|
         known_digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), subscription.secret.to_s, payload)
         basic.merge('X-Hub-Signature' => "sha1=#{known_digest}") if subscription.secret?