Back to Repositories

Testing Fog Storage Adapter Implementation in Paperclip

This test suite verifies the Fog storage adapter implementation for Paperclip, focusing on AWS S3 and local storage integration. It validates credential handling, path interpolation, URL generation, and bucket management across different configuration scenarios.

Test Coverage Overview

The test suite provides comprehensive coverage of Paperclip’s Fog storage adapter functionality:
  • AWS S3 credential handling from files and objects
  • Path and URL interpolation with default and custom settings
  • Bucket creation and management
  • Public and private file access controls
  • URL generation with custom hosts and expiring URLs
  • Local storage adapter functionality

Implementation Analysis

The testing approach uses RSpec’s context-based structure to organize related test scenarios. It leverages Fog’s mocking capabilities for AWS S3 testing and implements real local storage tests. The suite demonstrates thorough validation of configuration options and storage behaviors through isolated test cases.

Key patterns include setup/teardown with before/after blocks, shared context usage, and comprehensive edge case handling.

Technical Details

Testing tools and configuration:
  • RSpec as the testing framework
  • Fog for AWS S3 and local storage simulation
  • Timecop for time-dependent tests
  • Mock objects and stubs for isolation
  • Fixture files for upload testing
  • Custom model rebuilding for different configurations

Best Practices Demonstrated

The test suite exemplifies several testing best practices:
  • Proper test isolation through mocking and cleanup
  • Comprehensive edge case coverage
  • Clear test organization and naming
  • Effective use of setup and teardown
  • Thorough validation of configuration options
  • Proper resource cleanup

thoughtbot/paperclip

spec/paperclip/storage/fog_spec.rb

            
require 'spec_helper'
require 'fog/aws'
require 'fog/local'
require 'timecop'

describe Paperclip::Storage::Fog do
  context "" do
    before { Fog.mock! }

    context "with credentials provided in a path string" do
      before do
        rebuild_model styles: { medium: "300x300>", thumb: "100x100>" },
                      storage: :fog,
                      url: '/:attachment/:filename',
                      fog_directory: "paperclip",
                      fog_credentials: fixture_file('fog.yml')
        @file = File.new(fixture_file('5k.png'), 'rb')
        @dummy = Dummy.new
        @dummy.avatar = @file
      end

      after { @file.close }

      it "has the proper information loading credentials from a file" do
        assert_equal @dummy.avatar.fog_credentials[:provider], 'AWS'
      end
    end

    context "with credentials provided in a File object" do
      before do
        rebuild_model styles: { medium: "300x300>", thumb: "100x100>" },
                      storage: :fog,
                      url: '/:attachment/:filename',
                      fog_directory: "paperclip",
                      fog_credentials: File.open(fixture_file('fog.yml'))
        @file = File.new(fixture_file('5k.png'), 'rb')
        @dummy = Dummy.new
        @dummy.avatar = @file
      end

      after { @file.close }

      it "has the proper information loading credentials from a file" do
        assert_equal @dummy.avatar.fog_credentials[:provider], 'AWS'
      end
    end

    context "with default values for path and url" do
      before do
        rebuild_model styles: { medium: "300x300>", thumb: "100x100>" },
                      storage: :fog,
                      url: '/:attachment/:filename',
                      fog_directory: "paperclip",
                      fog_credentials: {
                        provider: 'AWS',
                        aws_access_key_id: 'AWS_ID',
                        aws_secret_access_key: 'AWS_SECRET'
                      }
        @file = File.new(fixture_file('5k.png'), 'rb')
        @dummy = Dummy.new
        @dummy.avatar = @file
      end

      after { @file.close }

      it "is able to interpolate the path without blowing up" do
        assert_equal File.expand_path(File.join(File.dirname(__FILE__), "../../../tmp/public/avatars/5k.png")),
          @dummy.avatar.path
      end
    end

    context "with no path or url given and using defaults" do
      before do
        rebuild_model styles: { medium: "300x300>", thumb: "100x100>" },
                      storage: :fog,
                      fog_directory: "paperclip",
                      fog_credentials: {
                        provider: 'AWS',
                        aws_access_key_id: 'AWS_ID',
                        aws_secret_access_key: 'AWS_SECRET'
                      }
        @file = File.new(fixture_file('5k.png'), 'rb')
        @dummy = Dummy.new
        @dummy.id = 1
        @dummy.avatar = @file
      end

      after { @file.close }

      it "has correct path and url from interpolated defaults" do
        assert_equal "dummies/avatars/000/000/001/original/5k.png", @dummy.avatar.path
      end
    end

    context "with file params provided as lambda" do
      before do
        fog_file = lambda{ |a| { custom_header: a.instance.custom_method }}
        klass = rebuild_model storage: :fog,
                              fog_file: fog_file

        klass.class_eval do
          def custom_method
            'foobar'
          end
        end


        @dummy = Dummy.new
      end

      it "is able to evaluate correct values for file headers" do
        assert_equal @dummy.avatar.send(:fog_file), { custom_header: 'foobar' }
      end
    end

    before do
      @fog_directory = 'papercliptests'

      @credentials = {
        provider: 'AWS',
        aws_access_key_id: 'ID',
        aws_secret_access_key: 'SECRET'
      }

      @connection = Fog::Storage.new(@credentials)
      @connection.directories.create(
        key: @fog_directory
      )

      @options = {
        fog_directory: @fog_directory,
        fog_credentials: @credentials,
        fog_host: nil,
        fog_file: {cache_control: 1234},
        path: ":attachment/:basename:dotextension",
        storage: :fog
      }

      rebuild_model(@options)
    end

    it "is extended by the Fog module" do
      assert Dummy.new.avatar.is_a?(Paperclip::Storage::Fog)
    end

    context "when assigned" do
      before do
        @file = File.new(fixture_file('5k.png'), 'rb')
        @dummy = Dummy.new
        @dummy.avatar = @file
      end

      after do
        @file.close
        directory = @connection.directories.new(key: @fog_directory)
        directory.files.each {|file| file.destroy}
        directory.destroy
      end

      it "is rewound after flush_writes" do
        @dummy.avatar.instance_eval "def after_flush_writes; end"

        files = @dummy.avatar.queued_for_write.values
        @dummy.save
        assert files.none?(&:eof?), "Expect all the files to be rewinded."
      end

      it "is removed after after_flush_writes" do
        paths = @dummy.avatar.queued_for_write.values.map(&:path)
        @dummy.save
        assert paths.none?{ |path| File.exist?(path) },
          "Expect all the files to be deleted."
      end

      it 'is able to be copied to a local file' do
        @dummy.save
        tempfile = Tempfile.new("known_location")
        tempfile.binmode
        @dummy.avatar.copy_to_local_file(:original, tempfile.path)
        tempfile.rewind
        assert_equal @connection.directories.get(@fog_directory).files.get(@dummy.avatar.path).body,
                     tempfile.read
        tempfile.close
      end

      it 'is able to be handled when missing while copying to a local file' do
        tempfile = Tempfile.new("known_location")
        tempfile.binmode
        assert_equal false, @dummy.avatar.copy_to_local_file(:original, tempfile.path)
        tempfile.close
      end

      it "passes the content type to the Fog::Storage::AWS::Files instance" do
        Fog::Storage::AWS::Files.any_instance.expects(:create).with do |hash|
          hash[:content_type]
        end
        @dummy.save
      end

      context "without a bucket" do
        before do
          @connection.directories.get(@fog_directory).destroy
        end

        it "creates the bucket" do
          assert @dummy.save
          assert @connection.directories.get(@fog_directory)
        end

        it "sucessfully rewinds the file during bucket creation" do
          assert @dummy.save
          expect(Paperclip.io_adapters.for(@dummy.avatar).read.length).to be > 0
        end
      end

      context "with a bucket" do
        it "succeeds" do
          assert @dummy.save
        end
      end

      context "without a fog_host" do
        before do
          rebuild_model(@options.merge(fog_host: nil))
          @dummy = Dummy.new
          @dummy.avatar = StringIO.new('.')
          @dummy.save
        end

        it "provides a public url" do
          assert [email protected]?
        end
      end

      context "with a fog_host" do
        before do
          rebuild_model(@options.merge(fog_host: 'http://example.com'))
          @dummy = Dummy.new
          @dummy.avatar = StringIO.new(".
")
          @dummy.save
        end

        it "provides a public url" do
          expect(@dummy.avatar.url).to match(/^http:\/\/example\.com\/avatars\/data\?\d*$/)
        end
      end

      context "with a fog_host that includes a wildcard placeholder" do
        before do
          rebuild_model(
            fog_directory: @fog_directory,
            fog_credentials: @credentials,
            fog_host: 'http://img%d.example.com',
            path: ":attachment/:basename:dotextension",
            storage: :fog
          )
          @dummy = Dummy.new
          @dummy.avatar = StringIO.new(".
")
          @dummy.save
        end

        it "provides a public url" do
          expect(@dummy.avatar.url).to match(/^http:\/\/img[0123]\.example\.com\/avatars\/data\?\d*$/)
        end
      end

      context "with fog_public set to false" do
        before do
          rebuild_model(@options.merge(fog_public: false))
          @dummy = Dummy.new
          @dummy.avatar = StringIO.new('.')
          @dummy.save
        end

        it 'sets the @fog_public instance variable to false' do
          assert_equal false, @dummy.avatar.instance_variable_get('@options')[:fog_public]
          assert_equal false, @dummy.avatar.fog_public
        end
      end

      context "with fog_public as a proc" do
        let(:proc) { ->(attachment) { !attachment } }

        before do
          rebuild_model(@options.merge(fog_public: proc))
          @dummy = Dummy.new
          @dummy.avatar = StringIO.new(".")
          @dummy.save
        end

        it "sets the @fog_public instance variable to false" do
          assert_equal proc, @dummy.avatar.instance_variable_get("@options")[:fog_public]
          assert_equal false, @dummy.avatar.fog_public
        end
      end

      context "with styles set and fog_public set to false" do
        before do
          rebuild_model(@options.merge(fog_public: false, styles: { medium: "300x300>", thumb: "100x100>" }))
          @file = File.new(fixture_file('5k.png'), 'rb')
          @dummy = Dummy.new
          @dummy.avatar = @file
          @dummy.save
        end

        it 'sets the @fog_public for a particular style to false' do
          assert_equal false, @dummy.avatar.instance_variable_get('@options')[:fog_public]
          assert_equal false, @dummy.avatar.fog_public(:thumb)
        end
      end

      context "with styles set and fog_public set per-style" do
        before do
          rebuild_model(@options.merge(fog_public: { medium: false, thumb: true}, styles: { medium: "300x300>", thumb: "100x100>" }))
          @file = File.new(fixture_file('5k.png'), 'rb')
          @dummy = Dummy.new
          @dummy.avatar = @file
          @dummy.save
        end

        it 'sets the fog_public for a particular style to correct value' do
          assert_equal false, @dummy.avatar.fog_public(:medium)
          assert_equal true, @dummy.avatar.fog_public(:thumb)
        end
      end

      context "with fog_public not set" do
        before do
          rebuild_model(@options)
          @dummy = Dummy.new
          @dummy.avatar = StringIO.new('.')
          @dummy.save
        end

        it "defaults fog_public to true" do
          assert_equal true, @dummy.avatar.fog_public
        end
      end

      context "with scheme set" do
        before do
          rebuild_model(@options.merge(:fog_credentials => @credentials.merge(:scheme => 'http')))
          @file = File.new(fixture_file('5k.png'), 'rb')
          @dummy = Dummy.new
          @dummy.avatar = @file
          @dummy.save
        end

        it "honors the scheme in public url" do
          assert_match(/^http:\/\//, @dummy.avatar.url)
        end
        it "honors the scheme in expiring url" do
          assert_match(/^http:\/\//, @dummy.avatar.expiring_url)
        end
      end

      context "with scheme not set" do
        before do
          rebuild_model(@options)
          @file = File.new(fixture_file('5k.png'), 'rb')
          @dummy = Dummy.new
          @dummy.avatar = @file
          @dummy.save
        end

        it "provides HTTPS public url" do
          assert_match(/^https:\/\//, @dummy.avatar.url)
        end
        it "provides HTTPS expiring url" do
          assert_match(/^https:\/\//, @dummy.avatar.expiring_url)
        end
      end

      context "with a valid bucket name for a subdomain" do
        before { @dummy.stubs(:new_record?).returns(false) }

        it "provides an url in subdomain style" do
          assert_match(/^https:\/\/papercliptests.s3.amazonaws.com\/avatars\/5k.png/, @dummy.avatar.url)
        end

        it "provides an url that expires in subdomain style" do
          assert_match(/^https:\/\/papercliptests.s3.amazonaws.com\/avatars\/5k.png.+Expires=.+$/, @dummy.avatar.expiring_url)
        end
      end

      context "generating an expiring url" do
        it "generates the same url when using Times and Integer offsets" do
          Timecop.freeze do
            offset = 1234
            rebuild_model(@options)
            dummy = Dummy.new
            dummy.avatar = StringIO.new('.')

            assert_equal dummy.avatar.expiring_url(offset),
              dummy.avatar.expiring_url(Time.now + offset )
          end
        end

        it 'matches the default url if there is no assignment' do
          dummy = Dummy.new
          assert_equal dummy.avatar.url, dummy.avatar.expiring_url
        end

        it 'matches the default url when given a style if there is no assignment' do
          dummy = Dummy.new
          assert_equal dummy.avatar.url(:thumb), dummy.avatar.expiring_url(3600, :thumb)
        end
      end

      context "with an invalid bucket name for a subdomain" do
        before do
          rebuild_model(@options.merge(fog_directory: "this_is_invalid"))
          @dummy = Dummy.new
          @dummy.avatar = @file
          @dummy.save
        end

        it "does not match the bucket-subdomain restrictions" do
          invalid_subdomains = %w(this_is_invalid in iamareallylongbucketnameiamareallylongbucketnameiamareallylongbu invalid- inval..id inval-.id inval.-id -invalid 192.168.10.2)
          invalid_subdomains.each do |name|
            assert_no_match Paperclip::Storage::Fog::AWS_BUCKET_SUBDOMAIN_RESTRICTON_REGEX, name
          end
        end

        it "provides an url in folder style" do
          assert_match(/^https:\/\/s3.amazonaws.com\/this_is_invalid\/avatars\/5k.png\?\d*$/, @dummy.avatar.url)
        end

        it "provides a url that expires in folder style" do
          assert_match(/^https:\/\/s3.amazonaws.com\/this_is_invalid\/avatars\/5k.png.+Expires=.+$/, @dummy.avatar.expiring_url)
        end

      end

      context "with a proc for a bucket name evaluating a model method" do
        before do
          @dynamic_fog_directory = 'dynamicpaperclip'
          rebuild_model(@options.merge(fog_directory: lambda { |attachment| attachment.instance.bucket_name }))
          @dummy = Dummy.new
          @dummy.stubs(:bucket_name).returns(@dynamic_fog_directory)
          @dummy.avatar = @file
          @dummy.save
        end

        it "has created the bucket" do
          assert @connection.directories.get(@dynamic_fog_directory).inspect
        end

        it "provides an url using dynamic bucket name" do
          assert_match(/^https:\/\/dynamicpaperclip.s3.amazonaws.com\/avatars\/5k.png\?\d*$/, @dummy.avatar.url)
        end
      end

      context "with a proc for the fog_host evaluating a model method" do
        before do
          rebuild_model(@options.merge(fog_host: lambda { |attachment| attachment.instance.fog_host }))
          @dummy = Dummy.new
          @dummy.stubs(:fog_host).returns('http://dynamicfoghost.com')
          @dummy.avatar = @file
          @dummy.save
        end

        it "provides a public url" do
          assert_match(/http:\/\/dynamicfoghost\.com/, @dummy.avatar.url)
        end

      end

      context "with a custom fog_host" do
        before do
          rebuild_model(@options.merge(fog_host: "http://dynamicfoghost.com"))
          @dummy = Dummy.new
          @dummy.avatar = @file
          @dummy.save
        end

        it "provides a public url" do
          assert_match(/http:\/\/dynamicfoghost\.com/, @dummy.avatar.url)
        end

        it "provides an expiring url" do
          assert_match(/http:\/\/dynamicfoghost\.com/, @dummy.avatar.expiring_url)
        end

        context "with an invalid bucket name for a subdomain" do
          before do
            rebuild_model(@options.merge({fog_directory: "this_is_invalid", fog_host: "http://dynamicfoghost.com"}))
            @dummy = Dummy.new
            @dummy.avatar = @file
            @dummy.save
          end

          it "provides an expiring url" do
            assert_match(/http:\/\/dynamicfoghost\.com/, @dummy.avatar.expiring_url)
          end
        end

      end

      context "with a proc for the fog_credentials evaluating a model method" do
        before do
          @dynamic_fog_credentials = {
            provider: 'AWS',
            aws_access_key_id: 'DYNAMIC_ID',
            aws_secret_access_key: 'DYNAMIC_SECRET'
          }
          rebuild_model(@options.merge(fog_credentials: lambda { |attachment| attachment.instance.fog_credentials }))
          @dummy = Dummy.new
          @dummy.stubs(:fog_credentials).returns(@dynamic_fog_credentials)
          @dummy.avatar = @file
          @dummy.save
        end

        it "provides a public url" do
          assert_equal @dummy.avatar.fog_credentials, @dynamic_fog_credentials
        end
      end

      context "with custom fog_options" do
        before do
          rebuild_model(
            @options.merge(fog_options: { multipart_chunk_size: 104857600 }),
          )
          @dummy = Dummy.new
          @dummy.avatar = @file
        end

        it "applies the options to the fog #create call" do
          files = stub
          @dummy.avatar.stubs(:directory).returns stub(files: files)
          files.expects(:create).with(
            has_entries(multipart_chunk_size: 104857600),
          )
          @dummy.save
        end
      end
    end

  end

  context "when using local storage" do
    before do
      Fog.unmock!
      rebuild_model styles: { medium: "300x300>", thumb: "100x100>" },
                    storage: :fog,
                    url: '/:attachment/:filename',
                    fog_directory: "paperclip",
                    fog_credentials: { provider: :local, local_root: "." },
                    fog_host: 'localhost'

      @file = File.new(fixture_file('5k.png'), 'rb')
      @dummy = Dummy.new
      @dummy.avatar = @file
      @dummy.stubs(:new_record?).returns(false)
    end

    after do
      @file.close
      Fog.mock!
    end

    it "returns the public url in place of the expiring url" do
      assert_match @dummy.avatar.public_url, @dummy.avatar.expiring_url
    end
  end
end